From 3bef3d1f4491c2268445c11deb825bdc0f9cca8b Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Sun, 1 Sep 2024 17:11:31 +0200 Subject: [PATCH 01/57] Here is a simple Graph to show historical data, which adds a feature request from https://github.com/home-assistant/android/issues/1529 --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 22 + .../android/HomeAssistantApplication.kt | 7 +- .../widgets/ManageWidgetsViewModel.kt | 5 +- .../widgets/views/ManageWidgetsView.kt | 15 +- .../android/widgets/graph/GraphWidget.kt | 450 ++++++ .../graph/GraphWidgetConfigureActivity.kt | 439 ++++++ .../widget_graph_wrapper_dynamiccolor.xml | 9 + app/src/main/res/layout/widget_graph.xml | 67 + .../res/layout/widget_graph_configure.xml | 286 ++++ .../layout/widget_graph_wrapper_default.xml | 9 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/graph_widget_info.xml | 18 + .../48.json | 1277 +++++++++++++++++ .../companion/android/database/AppDatabase.kt | 15 +- .../android/database/DatabaseModule.kt | 4 + .../android/database/sensor/SensorDao.kt | 8 +- .../android/database/widget/GraphWidgetDao.kt | 47 + .../database/widget/GraphWidgetEntity.kt | 33 + .../widget/GraphWidgetHistoryEntity.kt | 31 + .../widget/GraphWidgetWithHistories.kt | 22 + common/src/main/res/values/strings.xml | 2 + gradle/libs.versions.toml | 2 + settings.gradle.kts | 1 + 24 files changed, 2762 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt create mode 100644 app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt create mode 100644 app/src/main/res/layout-v31/widget_graph_wrapper_dynamiccolor.xml create mode 100644 app/src/main/res/layout/widget_graph.xml create mode 100644 app/src/main/res/layout/widget_graph_configure.xml create mode 100644 app/src/main/res/layout/widget_graph_wrapper_default.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/xml/graph_widget_info.xml create mode 100644 common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json create mode 100644 common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetEntity.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetWithHistories.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dc4760253dc..1f6c638a2a3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -118,6 +118,7 @@ android { dependencies { implementation(project(":common")) + implementation(libs.mpgraph) coreLibraryDesugaring(libs.tools.desugar.jdk) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f32435df433..0e8519befe3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -143,6 +143,20 @@ android:resource="@xml/entity_widget_info" /> + + + + + + + + + @@ -206,6 +220,14 @@ + + + + + diff --git a/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt b/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt index 1de9a2a8ec9..b89947184d0 100644 --- a/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt +++ b/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt @@ -24,14 +24,15 @@ import io.homeassistant.companion.android.util.LifecycleHandler import io.homeassistant.companion.android.websocket.WebsocketBroadcastReceiver import io.homeassistant.companion.android.widgets.button.ButtonWidget import io.homeassistant.companion.android.widgets.entity.EntityWidget +import io.homeassistant.companion.android.widgets.graph.GraphWidget import io.homeassistant.companion.android.widgets.mediaplayer.MediaPlayerControlsWidget import io.homeassistant.companion.android.widgets.template.TemplateWidget -import javax.inject.Inject -import javax.inject.Named import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Named @HiltAndroidApp open class HomeAssistantApplication : Application() { @@ -240,6 +241,7 @@ open class HomeAssistantApplication : Application() { // Update widgets when the screen turns on, updates are skipped if widgets were not added val buttonWidget = ButtonWidget() val entityWidget = EntityWidget() + val graphWidget = GraphWidget() val mediaPlayerWidget = MediaPlayerControlsWidget() val templateWidget = TemplateWidget() @@ -249,6 +251,7 @@ open class HomeAssistantApplication : Application() { registerReceiver(buttonWidget, screenIntentFilter) registerReceiver(entityWidget, screenIntentFilter) + registerReceiver(graphWidget, screenIntentFilter) registerReceiver(mediaPlayerWidget, screenIntentFilter) registerReceiver(templateWidget, screenIntentFilter) } diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt index 87917e40d88..5435f44fb18 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt @@ -13,18 +13,20 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.CameraWidgetDao +import io.homeassistant.companion.android.database.widget.GraphWidgetDao import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.StaticWidgetDao import io.homeassistant.companion.android.database.widget.TemplateWidgetDao -import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class ManageWidgetsViewModel @Inject constructor( buttonWidgetDao: ButtonWidgetDao, cameraWidgetDao: CameraWidgetDao, staticWidgetDao: StaticWidgetDao, + graphWidgetDao: GraphWidgetDao, mediaPlayerControlsWidgetDao: MediaPlayerControlsWidgetDao, templateWidgetDao: TemplateWidgetDao, application: Application @@ -39,6 +41,7 @@ class ManageWidgetsViewModel @Inject constructor( val buttonWidgetList = buttonWidgetDao.getAllFlow().collectAsState() val cameraWidgetList = cameraWidgetDao.getAllFlow().collectAsState() val staticWidgetList = staticWidgetDao.getAllFlow().collectAsState() + val graphWidgetList = graphWidgetDao.getAllFlow().collectAsState() val mediaWidgetList = mediaPlayerControlsWidgetDao.getAllFlow().collectAsState() val templateWidgetList = templateWidgetDao.getAllFlow().collectAsState() val supportsAddingWidgets: Boolean diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt index d1c6575d4b9..311b58861d2 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt @@ -42,6 +42,7 @@ import io.homeassistant.companion.android.util.compose.MdcAlertDialog import io.homeassistant.companion.android.widgets.button.ButtonWidgetConfigureActivity import io.homeassistant.companion.android.widgets.camera.CameraWidgetConfigureActivity import io.homeassistant.companion.android.widgets.entity.EntityWidgetConfigureActivity +import io.homeassistant.companion.android.widgets.graph.GraphWidgetConfigureActivity import io.homeassistant.companion.android.widgets.mediaplayer.MediaPlayerControlsWidgetConfigureActivity import io.homeassistant.companion.android.widgets.template.TemplateWidgetConfigureActivity @@ -49,6 +50,7 @@ enum class WidgetType(val widgetIcon: IIcon) { BUTTON(CommunityMaterial.Icon2.cmd_gesture_tap), CAMERA(CommunityMaterial.Icon.cmd_camera_image), STATE(CommunityMaterial.Icon3.cmd_shape), + GRAPH(CommunityMaterial.Icon2.cmd_file_chart), MEDIA(CommunityMaterial.Icon3.cmd_play_box_multiple), TEMPLATE(CommunityMaterial.Icon.cmd_code_braces); @@ -57,6 +59,7 @@ enum class WidgetType(val widgetIcon: IIcon) { CAMERA -> CameraWidgetConfigureActivity::class.java MEDIA -> MediaPlayerControlsWidgetConfigureActivity::class.java STATE -> EntityWidgetConfigureActivity::class.java + GRAPH -> GraphWidgetConfigureActivity::class.java TEMPLATE -> TemplateWidgetConfigureActivity::class.java } } @@ -82,6 +85,7 @@ fun ManageWidgetsView( stringResource(R.string.widget_button_image_description) to WidgetType.BUTTON, stringResource(R.string.widget_camera_description) to WidgetType.CAMERA, stringResource(R.string.widget_static_image_description) to WidgetType.STATE, + stringResource(R.string.widget_graph_image_description) to WidgetType.GRAPH, stringResource(R.string.widget_media_player_description) to WidgetType.MEDIA, stringResource(R.string.template_widget) to WidgetType.TEMPLATE ).sortedBy { it.first } @@ -110,7 +114,7 @@ fun ManageWidgetsView( ) { if (viewModel.buttonWidgetList.value.isEmpty() && viewModel.staticWidgetList.value.isEmpty() && viewModel.mediaWidgetList.value.isEmpty() && viewModel.templateWidgetList.value.isEmpty() && - viewModel.cameraWidgetList.value.isEmpty() + viewModel.templateWidgetList.value.isEmpty() && viewModel.graphWidgetList.value.isEmpty() ) { item { EmptyState( @@ -144,6 +148,15 @@ fun ManageWidgetsView( if (!label.isNullOrEmpty()) label else "${item.entityId} ${item.stateSeparator} ${item.attributeIds.orEmpty()}" } ) + widgetItems( + viewModel.graphWidgetList.value, + widgetType = WidgetType.GRAPH, + title = R.string.graph_state_widgets, + widgetLabel = { item -> + val label = item.label + if (!label.isNullOrEmpty()) label else "${item.entityId} ${item.stateSeparator} ${item.attributeIds.orEmpty()}" + } + ) widgetItems( viewModel.mediaWidgetList.value, widgetType = WidgetType.MEDIA, diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt new file mode 100644 index 00000000000..cd9fe14807a --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt @@ -0,0 +1,450 @@ +package io.homeassistant.companion.android.widgets.graph + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.os.Bundle +import android.text.format.DateUtils +import android.util.Log +import android.util.TypedValue +import android.view.View +import android.widget.RemoteViews +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.graphics.toColorInt +import androidx.core.os.BundleCompat +import com.fasterxml.jackson.annotation.ObjectIdGenerators.UUIDGenerator +import com.github.mikephil.charting.charts.LineChart +import com.github.mikephil.charting.components.Legend +import com.github.mikephil.charting.components.XAxis +import com.github.mikephil.charting.data.Entry +import com.github.mikephil.charting.data.LineData +import com.github.mikephil.charting.data.LineDataSet +import com.github.mikephil.charting.formatter.ValueFormatter +import com.google.android.material.color.DynamicColors +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.canSupportPrecision +import io.homeassistant.companion.android.common.data.integration.friendlyState +import io.homeassistant.companion.android.common.data.integration.onEntityPressedWithoutState +import io.homeassistant.companion.android.database.widget.GraphWidgetDao +import io.homeassistant.companion.android.database.widget.GraphWidgetEntity +import io.homeassistant.companion.android.database.widget.GraphWidgetHistoryEntity +import io.homeassistant.companion.android.database.widget.GraphWidgetWithHistories +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.database.widget.WidgetTapAction +import io.homeassistant.companion.android.util.getAttribute +import io.homeassistant.companion.android.widgets.BaseWidgetProvider +import kotlinx.coroutines.launch +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR + +@AndroidEntryPoint +class GraphWidget : BaseWidgetProvider() { + + companion object { + private const val TAG = "GraphWidget" + internal const val TOGGLE_ENTITY = + "io.homeassistant.companion.android.widgets.entity.GraphWidget.TOGGLE_ENTITY" + + internal const val EXTRA_SERVER_ID = "EXTRA_SERVER_ID" + internal const val EXTRA_ENTITY_ID = "EXTRA_ENTITY_ID" + internal const val EXTRA_ATTRIBUTE_IDS = "EXTRA_ATTRIBUTE_IDS" + internal const val EXTRA_LABEL = "EXTRA_LABEL" + internal const val EXTRA_TEXT_SIZE = "EXTRA_TEXT_SIZE" + internal const val EXTRA_STATE_SEPARATOR = "EXTRA_STATE_SEPARATOR" + internal const val EXTRA_ATTRIBUTE_SEPARATOR = "EXTRA_ATTRIBUTE_SEPARATOR" + internal const val EXTRA_TAP_ACTION = "EXTRA_TAP_ACTION" + internal const val EXTRA_BACKGROUND_TYPE = "EXTRA_BACKGROUND_TYPE" + internal const val EXTRA_TEXT_COLOR = "EXTRA_TEXT_COLOR" + + private data class ResolvedText(val text: CharSequence?, val exception: Boolean = false) + } + + @Inject + lateinit var graphWidgetDao: GraphWidgetDao + + override fun getWidgetProvider(context: Context): ComponentName = + ComponentName(context, GraphWidget::class.java) + + override suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int, suggestedEntity: Entity>?): RemoteViews { + val historicData = graphWidgetDao.getWithHistories(appWidgetId) + val widget = historicData?.graphWidget + + val intent = Intent(context, GraphWidget::class.java).apply { + action = if (widget?.tapAction == WidgetTapAction.TOGGLE) TOGGLE_ENTITY else UPDATE_VIEW + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + + //TODO + val appWidgetManager = AppWidgetManager.getInstance(context) + val options = appWidgetManager.getAppWidgetOptions(appWidgetId) + val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) + val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) + + // Convert dp to pixels for width and height + val width = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + minWidth.toFloat(), + context.resources.displayMetrics + ).toInt() + + val height = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + minHeight.toFloat(), + context.resources.displayMetrics + ).toInt() + + + val useDynamicColors = widget?.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable() + val views = RemoteViews(context.packageName, if (useDynamicColors) R.layout.widget_graph_wrapper_dynamiccolor else R.layout.widget_graph_wrapper_default) + .apply { + if (widget != null) { + val serverId = widget.serverId + val entityId: String = widget.entityId + val attributeIds: String? = widget.attributeIds + val label: String? = widget.label + val stateSeparator: String = widget.stateSeparator + val attributeSeparator: String = widget.attributeSeparator + + // Theming + if (widget.backgroundType == WidgetBackgroundType.TRANSPARENT) { + var textColor = context.getAttribute(R.attr.colorWidgetOnBackground, ContextCompat.getColor(context, commonR.color.colorWidgetButtonLabel)) + widget.textColor?.let { textColor = it.toColorInt() } + + setInt(R.id.widgetLayout, "setBackgroundColor", Color.TRANSPARENT) + setTextColor(R.id.widgetLabel, textColor) + } + + // Content + setViewVisibility( + R.id.widgetTextLayout, + View.VISIBLE + ) + setViewVisibility( + R.id.widgetProgressBar, + View.INVISIBLE + ) + val resolvedText = resolveTextToShow( + context, + serverId, + entityId, + suggestedEntity, + attributeIds, + stateSeparator, + attributeSeparator, + appWidgetId + ) + + setTextViewText( + R.id.widgetLabel, + label ?: entityId + ) + setViewVisibility( + R.id.widgetStaticError, + if (resolvedText.exception) View.VISIBLE else View.GONE + ) + setImageViewBitmap( + R.id.chartImageView, createLineChart( + context = context, + label = label ?: entityId, + entries = + createEntriesFromHistoricData( + historicData = historicData + ), + width = width, + height = height + ).chartBitmap + ) + setViewVisibility( + R.id.chartImageView, + if (!resolvedText.exception) View.VISIBLE else View.GONE + ) + setOnClickPendingIntent( + R.id.widgetTextLayout, + PendingIntent.getBroadcast( + context, + appWidgetId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) + } else { + setTextViewText(R.id.widgetText, "") + setTextViewText(R.id.widgetLabel, "") + } + } + + return views + } + + private fun createEntriesFromHistoricData(historicData: GraphWidgetWithHistories): List { + val entries = mutableListOf() + entries.add(Entry(0F, historicData.graphWidget.lastUpdate.toFloat())) + historicData.getOrderedHistories( + startTime = System.currentTimeMillis() - (60 * 60 * 3000), + endTime = System.currentTimeMillis() + )?.forEachIndexed { index, history -> + entries.add(Entry(index.toFloat() + 1, history.state.toFloat())) + } + return entries + } + + private fun createLineChart(context: Context, label: String, entries: List, width: Int, height: Int): LineChart { + val lineChart = LineChart(context).apply { + + setBackgroundColor(Color.WHITE) + + setDrawBorders(false) + + xAxis.apply { + setDrawGridLines(true) + position = XAxis.XAxisPosition.BOTTOM + textColor = Color.DKGRAY + textSize = 12F + granularity = 2F + setAvoidFirstLastClipping(true) +// valueFormatter = TimeValueFormatter(entries.map { it.x }) DateUtils.getRelativeTimeSpanString( history.sentState) + isAutoScaleMinMaxEnabled = true + } + + axisLeft.apply { + setDrawGridLines(true) // Remove grid lines + textColor = Color.DKGRAY // Dark gray for less contrast + textSize = 12f // Slightly increase text size + } + + axisRight.apply { + setDrawGridLines(false) + setDrawLabels(false) + } + + legend.apply { + isEnabled = true + textColor = Color.DKGRAY + textSize = 12F + verticalAlignment = Legend.LegendVerticalAlignment.BOTTOM + horizontalAlignment = Legend.LegendHorizontalAlignment.RIGHT + orientation = Legend.LegendOrientation.HORIZONTAL + setDrawInside(false) + } + + legend.isEnabled = true + description.isEnabled = false + } + + val mainGraphColor = context.resources.getColor(io.homeassistant.companion.android.common.R.color.colorPrimary) + + + // Create LineDataSet with smooth blue color + val dataSet = LineDataSet(entries, label).apply { + color = mainGraphColor + lineWidth = 2F + circleRadius = 1F + setDrawCircleHole(false) + setCircleColor(mainGraphColor) + mode = LineDataSet.Mode.CUBIC_BEZIER // For smooth line + setDrawCircles(true) // Remove circles on data points + setDrawValues(false) // Remove value labels + } + + lineChart.data = LineData(dataSet) + + lineChart.layout(0, 0, width, height) + + return lineChart + } + + + private class TimeValueFormatter(private val timestamps: List) : ValueFormatter() { + override fun getFormattedValue(value: Float): String { + val index = value.toInt() + return if (index >= 0 && index < timestamps.size) { + DateUtils.getRelativeTimeSpanString( + timestamps[index], + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE + ).toString() + } else { + value.toString() + } + } + } + + + override suspend fun getAllWidgetIdsWithEntities(context: Context): Map>> = + graphWidgetDao.getAll().associate { it.id to (it.serverId to listOf(it.entityId)) } + + private suspend fun resolveTextToShow( + context: Context, + serverId: Int, + entityId: String?, + suggestedEntity: Entity>?, + attributeIds: String?, + stateSeparator: String, + attributeSeparator: String, + appWidgetId: Int + ): ResolvedText { + var entity: Entity>? = null + var entityCaughtException = false + try { + entity = if (suggestedEntity != null && suggestedEntity.entityId == entityId) { + suggestedEntity + } else { + entityId?.let { serverManager.integrationRepository(serverId).getEntity(it) } + } + } catch (e: Exception) { + Log.e(TAG, "Unable to fetch entity", e) + entityCaughtException = true + } + val entityOptions = if ( + entity?.canSupportPrecision() == true && + serverManager.getServer(serverId)?.version?.isAtLeast(2023, 3) == true + ) { + serverManager.webSocketRepository(serverId).getEntityRegistryFor(entity.entityId)?.options + } else { + null + } + if (attributeIds == null) { + graphWidgetDao.updateWidgetLastUpdate( + appWidgetId, + entity?.friendlyState(context, entityOptions) ?: graphWidgetDao.get(appWidgetId)?.lastUpdate ?: "" + ) + return ResolvedText(graphWidgetDao.get(appWidgetId)?.lastUpdate, entityCaughtException) + } + + try { + val fetchedAttributes = entity?.attributes as? Map<*, *> ?: mapOf() + val attributeValues = + attributeIds.split(",").map { id -> fetchedAttributes[id]?.toString() } + val lastUpdate = + entity?.friendlyState(context, entityOptions).plus(if (attributeValues.isNotEmpty()) stateSeparator else "") + .plus(attributeValues.joinToString(attributeSeparator)) + graphWidgetDao.updateWidgetLastUpdate(appWidgetId, lastUpdate) + return ResolvedText(lastUpdate) + } catch (e: Exception) { + Log.e(TAG, "Unable to fetch entity state and attributes", e) + } + return ResolvedText(graphWidgetDao.get(appWidgetId)?.lastUpdate, true) + } + + override fun saveEntityConfiguration(context: Context, extras: Bundle?, appWidgetId: Int) { + if (extras == null) return + + val serverId = if (extras.containsKey(EXTRA_SERVER_ID)) extras.getInt(EXTRA_SERVER_ID) else null + val entitySelection: String? = extras.getString(EXTRA_ENTITY_ID) + val attributeSelection: ArrayList? = extras.getStringArrayList(EXTRA_ATTRIBUTE_IDS) + val labelSelection: String? = extras.getString(EXTRA_LABEL) + val textSizeSelection: String? = extras.getString(EXTRA_TEXT_SIZE) + val stateSeparatorSelection: String? = extras.getString(EXTRA_STATE_SEPARATOR) + val attributeSeparatorSelection: String? = extras.getString(EXTRA_ATTRIBUTE_SEPARATOR) + val tapActionSelection = BundleCompat.getSerializable(extras, EXTRA_TAP_ACTION, WidgetTapAction::class.java) + ?: WidgetTapAction.REFRESH + val backgroundTypeSelection = BundleCompat.getSerializable(extras, EXTRA_BACKGROUND_TYPE, WidgetBackgroundType::class.java) + ?: WidgetBackgroundType.DAYNIGHT + val textColorSelection: String? = extras.getString(EXTRA_TEXT_COLOR) + + if (serverId == null || entitySelection == null) { + Log.e(TAG, "Did not receive complete service call data") + return + } + + widgetScope?.launch { + Log.d( + TAG, + "Saving entity state config data:" + System.lineSeparator() + + "entity id: " + entitySelection + System.lineSeparator() + + "attribute: " + (attributeSelection ?: "N/A") + ) + graphWidgetDao.add( + GraphWidgetEntity( + appWidgetId, + serverId, + entitySelection, + attributeSelection?.joinToString(","), + labelSelection, + textSizeSelection?.toFloatOrNull() ?: 30F, + stateSeparatorSelection ?: "", + attributeSeparatorSelection ?: "", + tapActionSelection, + graphWidgetDao.get(appWidgetId)?.lastUpdate ?: "", + backgroundTypeSelection, + textColorSelection + ) + ) + + onUpdate(context, AppWidgetManager.getInstance(context), intArrayOf(appWidgetId)) + } + } + + override suspend fun onEntityStateChanged(context: Context, appWidgetId: Int, entity: Entity<*>) { + widgetScope?.launch { + + // Clean up old entries before updating the widget + val oneHourInMillis = 60 * 60 * 1000L // 1 hour in milliseconds, this can be provided by UI + graphWidgetDao.deleteEntriesOlderThan(appWidgetId, oneHourInMillis) + + graphWidgetDao.add( + GraphWidgetHistoryEntity( + UUIDGenerator().generateId(entity.entityId + entity.lastChanged).toString(), + appWidgetId, + entity.friendlyState(context), + entity.lastChanged.timeInMillis + ) + ) + val views = getWidgetRemoteViews(context, appWidgetId, entity as Entity>) + AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, views) + } + } + + private fun toggleEntity(context: Context, appWidgetId: Int) { + widgetScope?.launch { + // Show progress bar as feedback + val appWidgetManager = AppWidgetManager.getInstance(context) + val loadingViews = RemoteViews(context.packageName, R.layout.widget_static) + loadingViews.setViewVisibility(R.id.widgetProgressBar, View.VISIBLE) + loadingViews.setViewVisibility(R.id.widgetTextLayout, View.GONE) + appWidgetManager.partiallyUpdateAppWidget(appWidgetId, loadingViews) + + var success = false + graphWidgetDao.get(appWidgetId)?.let { + try { + onEntityPressedWithoutState( + it.entityId, + serverManager.integrationRepository(it.serverId) + ) + success = true + } catch (e: Exception) { + Log.e(TAG, "Unable to send toggle service call", e) + } + } + + if (!success) { + Toast.makeText(context, commonR.string.action_failure, Toast.LENGTH_LONG).show() + + val views = getWidgetRemoteViews(context, appWidgetId) + appWidgetManager.updateAppWidget(appWidgetId, views) + } // else update will be triggered by websocket subscription + } + } + + override fun onReceive(context: Context, intent: Intent) { + val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) + super.onReceive(context, intent) + when (lastIntent) { + TOGGLE_ENTITY -> toggleEntity(context, appWidgetId) + } + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + widgetScope?.launch { + graphWidgetDao.deleteAll(appWidgetIds) + appWidgetIds.forEach { removeSubscription(it) } + } + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt new file mode 100644 index 00000000000..47e99d07106 --- /dev/null +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt @@ -0,0 +1,439 @@ +package io.homeassistant.companion.android.widgets.graph + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.LinearLayout.VISIBLE +import android.widget.MultiAutoCompleteTextView.CommaTokenizer +import android.widget.Spinner +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.core.graphics.toColorInt +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope +import com.google.android.material.color.DynamicColors +import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.common.data.integration.Entity +import io.homeassistant.companion.android.common.data.integration.EntityExt +import io.homeassistant.companion.android.common.data.integration.domain +import io.homeassistant.companion.android.common.data.integration.friendlyName +import io.homeassistant.companion.android.database.widget.GraphWidgetDao +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.database.widget.WidgetTapAction +import io.homeassistant.companion.android.databinding.WidgetGraphConfigureBinding +import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel +import io.homeassistant.companion.android.util.getHexForColor +import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity +import io.homeassistant.companion.android.widgets.BaseWidgetProvider +import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR + +@AndroidEntryPoint +class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { + + companion object { + private const val TAG: String = "GraphWidgetConfigAct" + private const val PIN_WIDGET_CALLBACK = "io.homeassistant.companion.android.widgets.entity.GraphWidgetConfigureActivity.PIN_WIDGET_CALLBACK" + } + + @Inject + lateinit var graphWidgetDao: GraphWidgetDao + override val dao get() = graphWidgetDao + + private var entities = mutableMapOf>>() + + private var selectedEntity: Entity? = null + private var appendAttributes: Boolean = false + private var selectedAttributeIds: ArrayList = ArrayList() + private var labelFromEntity = false + + private lateinit var binding: WidgetGraphConfigureBinding + + override val serverSelect: View + get() = binding.serverSelect + + override val serverSelectList: Spinner + get() = binding.serverSelectList + + private var requestLauncherSetup = false + + private var entityAdapter: SingleItemArrayAdapter>? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Set the result to CANCELED. This will cause the widget host to cancel + // out of the widget placement if the user presses the back button. + setResult(RESULT_CANCELED) + + binding = WidgetGraphConfigureBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.addButton.setOnClickListener { + if (requestLauncherSetup) { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + isValidServerId() + ) { + getSystemService()?.requestPinAppWidget( + ComponentName(this, GraphWidget::class.java), + null, + PendingIntent.getActivity( + this, + System.currentTimeMillis().toInt(), + Intent(this, GraphWidgetConfigureActivity::class.java).putExtra(PIN_WIDGET_CALLBACK, true).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + ) + ) + } else { + showAddWidgetError() + } + } else { + onAddWidget() + } + } + + // Find the widget id from the intent. + val intent = intent + val extras = intent.extras + if (extras != null) { + appWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + requestLauncherSetup = extras.getBoolean( + ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER, + false + ) + } + + // If this activity was started with an intent without an app widget ID, finish with an error. + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID && !requestLauncherSetup) { + finish() + return + } + + // TODO to save a bit of time Im temporally using this dao to get the widget info + val graphWidget = graphWidgetDao.get(appWidgetId) + + val tapActionValues = listOf(getString(commonR.string.widget_tap_action_toggle), getString(commonR.string.refresh)) + binding.tapActionList.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, tapActionValues) + + val backgroundTypeValues = mutableListOf( + getString(commonR.string.widget_background_type_daynight), + getString(commonR.string.widget_background_type_transparent) + ) + if (DynamicColors.isDynamicColorAvailable()) { + backgroundTypeValues.add(0, getString(commonR.string.widget_background_type_dynamiccolor)) + } + binding.backgroundType.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, backgroundTypeValues) + + if (graphWidget != null) { + binding.widgetTextConfigEntityId.setText(graphWidget.entityId) + binding.label.setText(graphWidget.label) + binding.textSize.setText(graphWidget.textSize.toInt().toString()) + binding.stateSeparator.setText(graphWidget.stateSeparator) + val entity = runBlocking { + try { + serverManager.integrationRepository(graphWidget.serverId).getEntity(graphWidget.entityId) + } catch (e: Exception) { + Log.e(TAG, "Unable to get entity information", e) + Toast.makeText(applicationContext, commonR.string.widget_entity_fetch_error, Toast.LENGTH_LONG) + .show() + null + } + } + + val attributeIds = graphWidget.attributeIds + if (!attributeIds.isNullOrEmpty()) { + binding.appendAttributeValueCheckbox.isChecked = true + appendAttributes = true + for (item in attributeIds.split(',')) + selectedAttributeIds.add(item) + binding.widgetTextConfigAttribute.setText(attributeIds.replace(",", ", ")) + binding.attributeValueLinearLayout.visibility = VISIBLE + binding.attributeSeparator.setText(graphWidget.attributeSeparator) + } + if (entity != null) { + selectedEntity = entity as Entity? + setupAttributes() + } + + val toggleable = entity?.domain in EntityExt.APP_PRESS_ACTION_DOMAINS + binding.tapAction.isVisible = toggleable + binding.tapActionList.setSelection(if (toggleable && graphWidget.tapAction == WidgetTapAction.TOGGLE) 0 else 1) + + binding.backgroundType.setSelection( + when { + graphWidget.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable() -> + backgroundTypeValues.indexOf(getString(commonR.string.widget_background_type_dynamiccolor)) + + graphWidget.backgroundType == WidgetBackgroundType.TRANSPARENT -> + backgroundTypeValues.indexOf(getString(commonR.string.widget_background_type_transparent)) + + else -> + backgroundTypeValues.indexOf(getString(commonR.string.widget_background_type_daynight)) + } + ) + binding.textColor.visibility = if (graphWidget.backgroundType == WidgetBackgroundType.TRANSPARENT) View.VISIBLE else View.GONE + binding.textColorWhite.isChecked = + graphWidget.textColor?.let { it.toColorInt() == ContextCompat.getColor(this, android.R.color.white) } ?: true + binding.textColorBlack.isChecked = + graphWidget.textColor?.let { it.toColorInt() == ContextCompat.getColor(this, commonR.color.colorWidgetButtonLabelBlack) } ?: false + + binding.addButton.setText(commonR.string.update_widget) + } else { + binding.backgroundType.setSelection(0) + } + entityAdapter = SingleItemArrayAdapter(this) { it?.entityId ?: "" } + + setupServerSelect(graphWidget?.serverId) + + binding.widgetTextConfigEntityId.setAdapter(entityAdapter) + binding.widgetTextConfigEntityId.onFocusChangeListener = dropDownOnFocus + binding.widgetTextConfigEntityId.onItemClickListener = entityDropDownOnItemClick + binding.widgetTextConfigAttribute.onFocusChangeListener = dropDownOnFocus + binding.widgetTextConfigAttribute.onItemClickListener = attributeDropDownOnItemClick + binding.widgetTextConfigAttribute.setOnClickListener { + if (!binding.widgetTextConfigAttribute.isPopupShowing) binding.widgetTextConfigAttribute.showDropDown() + } + + binding.appendAttributeValueCheckbox.setOnCheckedChangeListener { _, isChecked -> + binding.attributeValueLinearLayout.isVisible = isChecked + appendAttributes = isChecked + } + + binding.label.addTextChangedListener(labelTextChanged) + + binding.backgroundType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + binding.textColor.visibility = + if (parent?.adapter?.getItem(position) == getString(commonR.string.widget_background_type_transparent)) { + View.VISIBLE + } else { + View.GONE + } + } + + override fun onNothingSelected(parent: AdapterView<*>?) { + binding.textColor.visibility = View.GONE + } + } + + serverManager.defaultServers.forEach { server -> + lifecycleScope.launch { + try { + val fetchedEntities = serverManager.integrationRepository(server.id).getEntities().orEmpty() + entities[server.id] = fetchedEntities + if (server.id == selectedServerId) setAdapterEntities(server.id) + } catch (e: Exception) { + // If entities fail to load, it's okay to pass + // an empty map to the dynamicFieldAdapter + Log.e(TAG, "Failed to query entities", e) + } + } + } + } + + override fun onServerSelected(serverId: Int) { + selectedEntity = null + binding.widgetTextConfigEntityId.setText("") + setupAttributes() + setAdapterEntities(serverId) + } + + private fun setAdapterEntities(serverId: Int) { + entityAdapter?.let { adapter -> + adapter.clearAll() + if (entities[serverId] != null) { + adapter.addAll(entities[serverId].orEmpty().toMutableList()) + adapter.sort() + } + runOnUiThread { adapter.notifyDataSetChanged() } + } + } + + private val dropDownOnFocus = View.OnFocusChangeListener { view, hasFocus -> + if (hasFocus && view is AutoCompleteTextView) { + view.showDropDown() + } + } + + private val entityDropDownOnItemClick = + AdapterView.OnItemClickListener { parent, _, position, _ -> + selectedEntity = parent.getItemAtPosition(position) as Entity? + if (binding.label.text.isNullOrBlank() || labelFromEntity) { + selectedEntity?.friendlyName?.takeIf { it != selectedEntity?.entityId }?.let { name -> + binding.label.removeTextChangedListener(labelTextChanged) + binding.label.setText(name) + labelFromEntity = true + binding.label.addTextChangedListener(labelTextChanged) + } + } + setupAttributes() + } + + private val attributeDropDownOnItemClick = + AdapterView.OnItemClickListener { parent, _, position, _ -> + selectedAttributeIds.add(parent.getItemAtPosition(position) as String) + } + + private val labelTextChanged = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // Not implemented + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + // Not implemented + } + + override fun afterTextChanged(s: Editable?) { + labelFromEntity = false + } + } + + private fun setupAttributes() { + val fetchedAttributes = selectedEntity?.attributes as? Map + val attributesAdapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line) + binding.widgetTextConfigAttribute.setAdapter(attributesAdapter) + attributesAdapter.addAll(*fetchedAttributes?.keys.orEmpty().toTypedArray()) + binding.widgetTextConfigAttribute.setTokenizer(CommaTokenizer()) + runOnUiThread { + val toggleable = selectedEntity?.domain in EntityExt.APP_PRESS_ACTION_DOMAINS + binding.tapAction.isVisible = toggleable + binding.tapActionList.setSelection(if (toggleable) 0 else 1) + attributesAdapter.notifyDataSetChanged() + } + } + + private fun onAddWidget() { + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + showAddWidgetError() + return + } + try { + val context = this@GraphWidgetConfigureActivity + + // Set up a broadcast intent and pass the service call data as extras + val intent = Intent() + intent.action = BaseWidgetProvider.RECEIVE_DATA + intent.component = ComponentName(context, GraphWidget::class.java) + + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + + intent.putExtra( + GraphWidget.EXTRA_SERVER_ID, + selectedServerId!! + ) + + val entity = if (selectedEntity == null) { + binding.widgetTextConfigEntityId.text.toString() + } else { + selectedEntity!!.entityId + } + if (entity !in entities[selectedServerId].orEmpty().map { it.entityId }) { + showAddWidgetError() + return + } + intent.putExtra( + GraphWidget.EXTRA_ENTITY_ID, + entity + ) + + intent.putExtra( + GraphWidget.EXTRA_LABEL, + binding.label.text.toString() + ) + + intent.putExtra( + GraphWidget.EXTRA_TEXT_SIZE, + binding.textSize.text.toString() + ) + + intent.putExtra( + GraphWidget.EXTRA_STATE_SEPARATOR, + binding.stateSeparator.text.toString() + ) + + if (appendAttributes) { + val attributes = if (selectedAttributeIds.isEmpty()) { + binding.widgetTextConfigAttribute.text.toString() + } else { + selectedAttributeIds + } + intent.putExtra( + GraphWidget.EXTRA_ATTRIBUTE_IDS, + attributes + ) + + intent.putExtra( + GraphWidget.EXTRA_ATTRIBUTE_SEPARATOR, + binding.attributeSeparator.text.toString() + ) + } + + intent.putExtra( + GraphWidget.EXTRA_TAP_ACTION, + when (binding.tapActionList.selectedItemPosition) { + 0 -> WidgetTapAction.TOGGLE + else -> WidgetTapAction.REFRESH + } + ) + + intent.putExtra( + GraphWidget.EXTRA_BACKGROUND_TYPE, + when (binding.backgroundType.selectedItem as String?) { + getString(commonR.string.widget_background_type_dynamiccolor) -> WidgetBackgroundType.DYNAMICCOLOR + getString(commonR.string.widget_background_type_transparent) -> WidgetBackgroundType.TRANSPARENT + else -> WidgetBackgroundType.DAYNIGHT + } + ) + + intent.putExtra( + GraphWidget.EXTRA_TEXT_COLOR, + if (binding.backgroundType.selectedItem as String? == getString(commonR.string.widget_background_type_transparent)) { + getHexForColor(if (binding.textColorWhite.isChecked) android.R.color.white else commonR.color.colorWidgetButtonLabelBlack) + } else { + null + } + ) + + context.sendBroadcast(intent) + + // Make sure we pass back the original appWidgetId + setResult( + RESULT_OK, + Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + ) + finish() + } catch (e: Exception) { + Log.e(TAG, "Issue configuring widget", e) + showAddWidgetError() + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (intent.extras != null && intent.hasExtra(PIN_WIDGET_CALLBACK)) { + appWidgetId = intent.extras!!.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + onAddWidget() + } + } +} diff --git a/app/src/main/res/layout-v31/widget_graph_wrapper_dynamiccolor.xml b/app/src/main/res/layout-v31/widget_graph_wrapper_dynamiccolor.xml new file mode 100644 index 00000000000..a5a1d12f0f2 --- /dev/null +++ b/app/src/main/res/layout-v31/widget_graph_wrapper_dynamiccolor.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_graph.xml b/app/src/main/res/layout/widget_graph.xml new file mode 100644 index 00000000000..82f387a03a0 --- /dev/null +++ b/app/src/main/res/layout/widget_graph.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_graph_configure.xml b/app/src/main/res/layout/widget_graph_configure.xml new file mode 100644 index 00000000000..96cad5b8a51 --- /dev/null +++ b/app/src/main/res/layout/widget_graph_configure.xml @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/widget_graph_wrapper_default.xml b/app/src/main/res/layout/widget_graph_wrapper_default.xml new file mode 100644 index 00000000000..112cafc3c88 --- /dev/null +++ b/app/src/main/res/layout/widget_graph_wrapper_default.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 00000000000..0c188bee7c9 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Historical Graph + \ No newline at end of file diff --git a/app/src/main/res/xml/graph_widget_info.xml b/app/src/main/res/xml/graph_widget_info.xml new file mode 100644 index 00000000000..6a04cb5d495 --- /dev/null +++ b/app/src/main/res/xml/graph_widget_info.xml @@ -0,0 +1,18 @@ + + \ No newline at end of file diff --git a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json new file mode 100644 index 00000000000..a7b5c731517 --- /dev/null +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json @@ -0,0 +1,1277 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "74986bc349d97b572c9acdaa1cb7f26d", + "entities": [ + { + "tableName": "sensor_attributes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "authentication_list", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`host` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`host`))", + "fields": [ + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "host" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sensors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL, `registered` INTEGER DEFAULT NULL, `state` TEXT NOT NULL, `last_sent_state` TEXT DEFAULT NULL, `last_sent_icon` TEXT DEFAULT NULL, `state_type` TEXT NOT NULL, `type` TEXT NOT NULL, `icon` TEXT NOT NULL, `name` TEXT NOT NULL, `device_class` TEXT, `unit_of_measurement` TEXT, `state_class` TEXT, `entity_category` TEXT, `core_registration` TEXT, `app_registration` TEXT, PRIMARY KEY(`id`, `server_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registered", + "columnName": "registered", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSentState", + "columnName": "last_sent_state", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "lastSentIcon", + "columnName": "last_sent_icon", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "stateType", + "columnName": "state_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "deviceClass", + "columnName": "device_class", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unitOfMeasurement", + "columnName": "unit_of_measurement", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "stateClass", + "columnName": "state_class", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "entityCategory", + "columnName": "entity_category", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "coreRegistration", + "columnName": "core_registration", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "appRegistration", + "columnName": "app_registration", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "server_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sensor_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sensor_id` TEXT NOT NULL, `name` TEXT NOT NULL, `value` TEXT NOT NULL, `value_type` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `entries` TEXT NOT NULL, PRIMARY KEY(`sensor_id`, `name`))", + "fields": [ + { + "fieldPath": "sensorId", + "columnName": "sensor_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "value_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entries", + "columnName": "entries", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sensor_id", + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "button_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT NOT NULL, `domain` TEXT NOT NULL, `service` TEXT NOT NULL, `service_data` TEXT NOT NULL, `label` TEXT, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, `require_authentication` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "service", + "columnName": "service", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serviceData", + "columnName": "service_data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "requireAuthentication", + "columnName": "require_authentication", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "camera_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "media_player_controls_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `label` TEXT, `show_skip` INTEGER NOT NULL, `show_seek` INTEGER NOT NULL, `show_volume` INTEGER NOT NULL, `show_source` INTEGER NOT NULL DEFAULT false, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "showSkip", + "columnName": "show_skip", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSeek", + "columnName": "show_seek", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showVolume", + "columnName": "show_volume", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showSource", + "columnName": "show_source", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "static_widget", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeIds", + "columnName": "attribute_ids", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "stateSeparator", + "columnName": "state_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeSeparator", + "columnName": "attribute_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "graph_widget", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `entity_id` TEXT NOT NULL, `attribute_ids` TEXT, `label` TEXT, `text_size` REAL NOT NULL, `state_separator` TEXT NOT NULL, `attribute_separator` TEXT NOT NULL, `tap_action` TEXT NOT NULL DEFAULT 'REFRESH', `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeIds", + "columnName": "attribute_ids", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "stateSeparator", + "columnName": "state_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "attributeSeparator", + "columnName": "attribute_separator", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tapAction", + "columnName": "tap_action", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'REFRESH'" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "graph_widget_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`entity_id` TEXT NOT NULL, `graph_widget_id` INTEGER NOT NULL, `state` TEXT NOT NULL, `sent_state` INTEGER NOT NULL, PRIMARY KEY(`entity_id`, `graph_widget_id`), FOREIGN KEY(`graph_widget_id`) REFERENCES `graph_widget`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "graphWidgetId", + "columnName": "graph_widget_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sentState", + "columnName": "sent_state", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "entity_id", + "graph_widget_id" + ] + }, + "indices": [ + { + "name": "index_graph_widget_history_sent_state", + "unique": false, + "columnNames": [ + "sent_state" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_graph_widget_history_sent_state` ON `${TABLE_NAME}` (`sent_state`)" + } + ], + "foreignKeys": [ + { + "table": "graph_widget", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "graph_widget_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "template_widgets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `server_id` INTEGER NOT NULL DEFAULT 0, `template` TEXT NOT NULL, `text_size` REAL NOT NULL DEFAULT 12.0, `last_update` TEXT NOT NULL, `background_type` TEXT NOT NULL DEFAULT 'DAYNIGHT', `text_color` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "template", + "columnName": "template", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "textSize", + "columnName": "text_size", + "affinity": "REAL", + "notNull": true, + "defaultValue": "12.0" + }, + { + "fieldPath": "lastUpdate", + "columnName": "last_update", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "backgroundType", + "columnName": "background_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'DAYNIGHT'" + }, + { + "fieldPath": "textColor", + "columnName": "text_color", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `received` INTEGER NOT NULL, `message` TEXT NOT NULL, `data` TEXT NOT NULL, `source` TEXT NOT NULL, `server_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "received", + "columnName": "received", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "location_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `created` INTEGER NOT NULL, `trigger` TEXT NOT NULL, `result` TEXT NOT NULL, `latitude` REAL, `longitude` REAL, `location_name` TEXT, `accuracy` INTEGER, `data` TEXT, `server_id` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "trigger", + "columnName": "trigger", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "result", + "columnName": "result", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "locationName", + "columnName": "location_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accuracy", + "columnName": "accuracy", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "qs_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tile_id` TEXT NOT NULL, `added` INTEGER NOT NULL DEFAULT 1, `server_id` INTEGER NOT NULL DEFAULT 0, `icon_name` TEXT, `entity_id` TEXT NOT NULL, `label` TEXT NOT NULL, `subtitle` TEXT, `should_vibrate` INTEGER NOT NULL DEFAULT 0, `auth_required` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tileId", + "columnName": "tile_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "iconName", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subtitle", + "columnName": "subtitle", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shouldVibrate", + "columnName": "should_vibrate", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "authRequired", + "columnName": "auth_required", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `friendly_name` TEXT NOT NULL, `icon` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "friendlyName", + "columnName": "friendly_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "camera_tiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT, `refresh_interval` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "refreshInterval", + "columnName": "refresh_interval", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "entity_state_complications", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entity_id` TEXT NOT NULL, `show_title` INTEGER NOT NULL DEFAULT 1, `show_unit` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entityId", + "columnName": "entity_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "showTitle", + "columnName": "show_title", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "showUnit", + "columnName": "show_unit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `_name` TEXT NOT NULL, `name_override` TEXT, `_version` TEXT, `device_registry_id` TEXT, `list_order` INTEGER NOT NULL, `device_name` TEXT, `external_url` TEXT NOT NULL, `internal_url` TEXT, `cloud_url` TEXT, `webhook_id` TEXT, `secret` TEXT, `cloudhook_url` TEXT, `use_cloud` INTEGER NOT NULL, `internal_ssids` TEXT NOT NULL, `prioritize_internal` INTEGER NOT NULL, `access_token` TEXT, `refresh_token` TEXT, `token_expiration` INTEGER, `token_type` TEXT, `install_id` TEXT, `user_id` TEXT, `user_name` TEXT, `user_is_owner` INTEGER, `user_is_admin` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "_name", + "columnName": "_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nameOverride", + "columnName": "name_override", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "_version", + "columnName": "_version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "deviceRegistryId", + "columnName": "device_registry_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceName", + "columnName": "device_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.externalUrl", + "columnName": "external_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.internalUrl", + "columnName": "internal_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.cloudUrl", + "columnName": "cloud_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.webhookId", + "columnName": "webhook_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.secret", + "columnName": "secret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.cloudhookUrl", + "columnName": "cloudhook_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "connection.useCloud", + "columnName": "use_cloud", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "connection.internalSsids", + "columnName": "internal_ssids", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "connection.prioritizeInternal", + "columnName": "prioritize_internal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "session.accessToken", + "columnName": "access_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.refreshToken", + "columnName": "refresh_token", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.tokenExpiration", + "columnName": "token_expiration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "session.tokenType", + "columnName": "token_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "session.installId", + "columnName": "install_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.id", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.name", + "columnName": "user_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "user.isOwner", + "columnName": "user_is_owner", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "user.isAdmin", + "columnName": "user_is_admin", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websocket_setting` TEXT NOT NULL, `sensor_update_frequency` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "websocketSetting", + "columnName": "websocket_setting", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sensorUpdateFrequency", + "columnName": "sensor_update_frequency", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '74986bc349d97b572c9acdaa1cb7f26d')" + ] + } +} \ No newline at end of file diff --git a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt index 3f3b64f5b60..2d37aca4011 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt @@ -30,7 +30,6 @@ import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.util.CHANNEL_DATABASE import io.homeassistant.companion.android.database.authentication.Authentication @@ -65,6 +64,9 @@ import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.CameraWidgetDao import io.homeassistant.companion.android.database.widget.CameraWidgetEntity +import io.homeassistant.companion.android.database.widget.GraphWidgetDao +import io.homeassistant.companion.android.database.widget.GraphWidgetEntity +import io.homeassistant.companion.android.database.widget.GraphWidgetHistoryEntity import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetEntity import io.homeassistant.companion.android.database.widget.StaticWidgetDao @@ -73,8 +75,9 @@ import io.homeassistant.companion.android.database.widget.TemplateWidgetDao import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundTypeConverter import io.homeassistant.companion.android.database.widget.WidgetTapActionConverter -import java.util.UUID import kotlinx.coroutines.runBlocking +import java.util.UUID +import io.homeassistant.companion.android.common.R as commonR @Database( entities = [ @@ -86,6 +89,8 @@ import kotlinx.coroutines.runBlocking CameraWidgetEntity::class, MediaPlayerControlsWidgetEntity::class, StaticWidgetEntity::class, + GraphWidgetEntity::class, + GraphWidgetHistoryEntity::class, TemplateWidgetEntity::class, NotificationItem::class, LocationHistoryItem::class, @@ -97,7 +102,7 @@ import kotlinx.coroutines.runBlocking Server::class, Setting::class ], - version = 47, + version = 48, autoMigrations = [ AutoMigration(from = 24, to = 25), AutoMigration(from = 25, to = 26), @@ -120,7 +125,8 @@ import kotlinx.coroutines.runBlocking AutoMigration(from = 43, to = 44), AutoMigration(from = 44, to = 45), AutoMigration(from = 45, to = 46), - AutoMigration(from = 46, to = 47) + AutoMigration(from = 46, to = 47), + AutoMigration(from = 47, to = 48) ] ) @TypeConverters( @@ -138,6 +144,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun cameraWidgetDao(): CameraWidgetDao abstract fun mediaPlayCtrlWidgetDao(): MediaPlayerControlsWidgetDao abstract fun staticWidgetDao(): StaticWidgetDao + abstract fun graphWidgetDao(): GraphWidgetDao abstract fun templateWidgetDao(): TemplateWidgetDao abstract fun notificationDao(): NotificationDao abstract fun locationHistoryDao(): LocationHistoryDao diff --git a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt index 0f92c5473ca..6023af8f222 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt @@ -19,6 +19,7 @@ import io.homeassistant.companion.android.database.wear.FavoriteCachesDao import io.homeassistant.companion.android.database.wear.FavoritesDao import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.CameraWidgetDao +import io.homeassistant.companion.android.database.widget.GraphWidgetDao import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.StaticWidgetDao import io.homeassistant.companion.android.database.widget.TemplateWidgetDao @@ -52,6 +53,9 @@ object DatabaseModule { @Provides fun provideStaticWidgetDao(database: AppDatabase): StaticWidgetDao = database.staticWidgetDao() + @Provides + fun provideGraphWidgetDao(database: AppDatabase): GraphWidgetDao = database.graphWidgetDao() + @Provides fun provideTemplateWidgetDao(database: AppDatabase): TemplateWidgetDao = database.templateWidgetDao() diff --git a/common/src/main/java/io/homeassistant/companion/android/database/sensor/SensorDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/sensor/SensorDao.kt index ae642a077b6..f6a1af26453 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/sensor/SensorDao.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/sensor/SensorDao.kt @@ -14,10 +14,10 @@ import kotlinx.coroutines.flow.Flow @Dao interface SensorDao { - @Query("SELECT * FROM Sensors WHERE id = :id") + @Query("SELECT * FROM sensors WHERE id = :id") fun get(id: String): List - @Query("SELECT * FROM Sensors WHERE id = :id AND server_id = :serverId") + @Query("SELECT * FROM sensors WHERE id = :id AND server_id = :serverId") fun get(id: String, serverId: Int): Sensor? @Query("SELECT * FROM sensors") @@ -35,10 +35,10 @@ interface SensorDao { @Query("SELECT * FROM sensors LEFT JOIN sensor_attributes ON sensors.id = sensor_attributes.sensor_id WHERE sensors.id = :id") fun getFullFlow(id: String): Flow>> - @Query("SELECT * FROM Sensors WHERE server_id = :serverId") + @Query("SELECT * FROM sensors WHERE server_id = :serverId") suspend fun getAllServer(serverId: Int): List - @Query("SELECT * FROM Sensors WHERE NOT(server_id IN (:serverIds))") + @Query("SELECT * FROM sensors WHERE NOT(server_id IN (:serverIds))") suspend fun getAllExceptServer(serverIds: List): List @Transaction diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt new file mode 100644 index 00000000000..165cc5daa39 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt @@ -0,0 +1,47 @@ +package io.homeassistant.companion.android.database.widget + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface GraphWidgetDao : WidgetDao { + + @Query("SELECT * FROM graph_widget WHERE id = :id") + fun get(id: Int): GraphWidgetEntity? + + @Query("SELECT * FROM graph_widget WHERE id = :id") + suspend fun getWithHistories(id: Int): GraphWidgetWithHistories? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun add(graphWidgetEntity: GraphWidgetEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun add(graphWidgetEntity: GraphWidgetHistoryEntity) + + // Delete a specific GraphWidgetEntity by id + @Query("DELETE FROM graph_widget WHERE id = :id") + override suspend fun delete(id: Int) + + // Delete multiple GraphWidgetEntity by ids + @Query("DELETE FROM graph_widget WHERE id IN (:ids)") + suspend fun deleteAll(ids: IntArray) + + // Retrieve all GraphWidgetEntity records + @Query("SELECT * FROM graph_widget") + suspend fun getAll(): List + + // Retrieve all GraphWidgetEntity records as a Flow + @Query("SELECT * FROM graph_widget") + fun getAllFlow(): Flow> + + // Update the last_update field of a specific GraphWidgetEntity + @Query("UPDATE graph_widget SET last_update = :lastUpdate WHERE id = :widgetId") + suspend fun updateWidgetLastUpdate(widgetId: Int, lastUpdate: String) + + @Query("DELETE FROM graph_widget_history WHERE graph_widget_id = :appWidgetId AND sent_state < :cutoffTime") + suspend fun deleteEntriesOlderThan(appWidgetId: Int, cutoffTime: Long) + +} diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetEntity.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetEntity.kt new file mode 100644 index 00000000000..a3a00f43c22 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetEntity.kt @@ -0,0 +1,33 @@ +package io.homeassistant.companion.android.database.widget + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "graph_widget") +data class GraphWidgetEntity( + @PrimaryKey + override val id: Int, + @ColumnInfo(name = "server_id", defaultValue = "0") + override val serverId: Int, + @ColumnInfo(name = "entity_id") + val entityId: String, + @ColumnInfo(name = "attribute_ids") + val attributeIds: String?, + @ColumnInfo(name = "label") + val label: String?, + @ColumnInfo(name = "text_size") + val textSize: Float = 30F, + @ColumnInfo(name = "state_separator") + val stateSeparator: String = "", + @ColumnInfo(name = "attribute_separator") + val attributeSeparator: String = "", + @ColumnInfo(name = "tap_action", defaultValue = "REFRESH") + val tapAction: WidgetTapAction, + @ColumnInfo(name = "last_update") + val lastUpdate: String, + @ColumnInfo(name = "background_type", defaultValue = "DAYNIGHT") + override val backgroundType: WidgetBackgroundType = WidgetBackgroundType.DAYNIGHT, + @ColumnInfo(name = "text_color") + override val textColor: String? = null +) : WidgetEntity, ThemeableWidgetEntity diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt new file mode 100644 index 00000000000..9c5d73024d5 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt @@ -0,0 +1,31 @@ +package io.homeassistant.companion.android.database.widget + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +@Entity( + tableName = "graph_widget_history", + primaryKeys = ["entity_id", "graph_widget_id"], + foreignKeys = [ + ForeignKey( + entity = GraphWidgetEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("graph_widget_id"), + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + ) + ], + indices = [Index("sent_state")] +) +data class GraphWidgetHistoryEntity( + @ColumnInfo(name = "entity_id") + val entityId: String, + @ColumnInfo(name = "graph_widget_id") + val graphWidgetId: Int, // This should match the type of GraphWidgetEntity's id + @ColumnInfo(name = "state") + val state: String, + @ColumnInfo(name = "sent_state") + var sentState: Long +) diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetWithHistories.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetWithHistories.kt new file mode 100644 index 00000000000..5dc319436b4 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetWithHistories.kt @@ -0,0 +1,22 @@ +package io.homeassistant.companion.android.database.widget + +import androidx.room.Embedded +import androidx.room.Relation + +data class GraphWidgetWithHistories( + @Embedded val graphWidget: GraphWidgetEntity, + @Relation( + parentColumn = "id", + entityColumn = "graph_widget_id" + ) + val histories: List? +) { + fun getOrderedHistories(startTime: Long? = null, endTime: Long? = null): List? { + return histories + ?.filter { history -> + (startTime == null || history.sentState >= startTime) && + (endTime == null || history.sentState <= endTime) + } + ?.sortedBy { it.sentState } + } +} diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 4b546c57782..17e754986a2 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -1022,6 +1022,7 @@ Server: State and attribute separator: Entity state + Graph of entity state Tap action: Toggle Unable to render the template @@ -1274,4 +1275,5 @@ Update available Up-to-date Health Connect sensors + Graph historic Widget diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ed39f374780..3c692a3b497 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -68,6 +68,7 @@ wear-remote-interactions = "1.0.0" workRuntimeKtx = "2.9.1" horologist = "0.5.28" zxing = "4.3.0" +mpgraph = "v3.1.0" [plugins] android-application = { id = "com.android.application", version.ref = "androidPlugin" } @@ -172,6 +173,7 @@ wear-tiles = { module = "androidx.wear.tiles:tiles", version.ref = "wear-tiles" wear-tooling = { module = "androidx.wear:wear-tooling-preview", version.ref = "wear-tooling" } webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } zxing = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxing" } +mpgraph = { module = "com.github.PhilJay:MPAndroidChart", version.ref = "mpgraph" } [bundles] horologist = ["horologist-layout", "horologist-composables"] diff --git a/settings.gradle.kts b/settings.gradle.kts index e274482110a..37307a80c85 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,5 +29,6 @@ dependencyResolutionManagement { mavenCentral() google() maven("https://jitpack.io") + maven("https://plugins.jetbrains.com/plugin/10766-database-debugger") } } From c66f918dbd9a23d2deb832fceb4bae7798c4627c Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Mon, 2 Sep 2024 02:29:01 +0200 Subject: [PATCH 02/57] Fix Lint Database Improvements --- .../widgets/ManageWidgetsViewModel.kt | 150 +++++++++--------- .../android/widgets/graph/GraphWidget.kt | 81 ++++++---- .../res/drawable/widget_example_graph.png | Bin 0 -> 17577 bytes app/src/main/res/layout/widget_graph.xml | 42 +++-- app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/graph_widget_info.xml | 22 +-- .../48.json | 13 +- .../android/database/widget/GraphWidgetDao.kt | 3 +- .../widget/GraphWidgetHistoryEntity.kt | 7 +- 9 files changed, 187 insertions(+), 133 deletions(-) create mode 100644 app/src/main/res/drawable/widget_example_graph.png diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt index 5435f44fb18..73a2cee0479 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt @@ -1,75 +1,75 @@ -package io.homeassistant.companion.android.settings.widgets - -import android.app.Application -import android.appwidget.AppWidgetManager -import android.os.Build -import android.os.RemoteException -import android.util.Log -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.core.content.getSystemService -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import io.homeassistant.companion.android.database.widget.ButtonWidgetDao -import io.homeassistant.companion.android.database.widget.CameraWidgetDao -import io.homeassistant.companion.android.database.widget.GraphWidgetDao -import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao -import io.homeassistant.companion.android.database.widget.StaticWidgetDao -import io.homeassistant.companion.android.database.widget.TemplateWidgetDao -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class ManageWidgetsViewModel @Inject constructor( - buttonWidgetDao: ButtonWidgetDao, - cameraWidgetDao: CameraWidgetDao, - staticWidgetDao: StaticWidgetDao, - graphWidgetDao: GraphWidgetDao, - mediaPlayerControlsWidgetDao: MediaPlayerControlsWidgetDao, - templateWidgetDao: TemplateWidgetDao, - application: Application -) : AndroidViewModel(application) { - companion object { - private const val TAG = "ManageWidgetsViewModel" - - const val CONFIGURE_REQUEST_LAUNCHER = - "io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER" - } - - val buttonWidgetList = buttonWidgetDao.getAllFlow().collectAsState() - val cameraWidgetList = cameraWidgetDao.getAllFlow().collectAsState() - val staticWidgetList = staticWidgetDao.getAllFlow().collectAsState() - val graphWidgetList = graphWidgetDao.getAllFlow().collectAsState() - val mediaWidgetList = mediaPlayerControlsWidgetDao.getAllFlow().collectAsState() - val templateWidgetList = templateWidgetDao.getAllFlow().collectAsState() - val supportsAddingWidgets: Boolean - - init { - supportsAddingWidgets = checkSupportsAddingWidgets() - } - - private fun checkSupportsAddingWidgets(): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val appWidgetManager = getApplication().getSystemService() - try { - return appWidgetManager?.isRequestPinAppWidgetSupported ?: false - } catch (e: RemoteException) { - Log.e(TAG, "Unable to read isRequestPinAppWidgetSupported", e) - } - } - return false - } - - /** - * Convert a Flow into a State object that updates until the view model is cleared. - */ - private fun Flow>.collectAsState(): State> { - val state = mutableStateOf(emptyList()) - viewModelScope.launch { - collect { state.value = it } - } - return state - } -} +package io.homeassistant.companion.android.settings.widgets + +import android.app.Application +import android.appwidget.AppWidgetManager +import android.os.Build +import android.os.RemoteException +import android.util.Log +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.core.content.getSystemService +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.database.widget.ButtonWidgetDao +import io.homeassistant.companion.android.database.widget.CameraWidgetDao +import io.homeassistant.companion.android.database.widget.GraphWidgetDao +import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao +import io.homeassistant.companion.android.database.widget.StaticWidgetDao +import io.homeassistant.companion.android.database.widget.TemplateWidgetDao +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ManageWidgetsViewModel @Inject constructor( + buttonWidgetDao: ButtonWidgetDao, + cameraWidgetDao: CameraWidgetDao, + staticWidgetDao: StaticWidgetDao, + graphWidgetDao: GraphWidgetDao, + mediaPlayerControlsWidgetDao: MediaPlayerControlsWidgetDao, + templateWidgetDao: TemplateWidgetDao, + application: Application +) : AndroidViewModel(application) { + companion object { + private const val TAG = "ManageWidgetsViewModel" + + const val CONFIGURE_REQUEST_LAUNCHER = + "io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER" + } + + val buttonWidgetList = buttonWidgetDao.getAllFlow().collectAsState() + val cameraWidgetList = cameraWidgetDao.getAllFlow().collectAsState() + val staticWidgetList = staticWidgetDao.getAllFlow().collectAsState() + val graphWidgetList = graphWidgetDao.getAllFlow().collectAsState() + val mediaWidgetList = mediaPlayerControlsWidgetDao.getAllFlow().collectAsState() + val templateWidgetList = templateWidgetDao.getAllFlow().collectAsState() + val supportsAddingWidgets: Boolean + + init { + supportsAddingWidgets = checkSupportsAddingWidgets() + } + + private fun checkSupportsAddingWidgets(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val appWidgetManager = getApplication().getSystemService() + try { + return appWidgetManager?.isRequestPinAppWidgetSupported ?: false + } catch (e: RemoteException) { + Log.e(TAG, "Unable to read isRequestPinAppWidgetSupported", e) + } + } + return false + } + + /** + * Convert a Flow into a State object that updates until the view model is cleared. + */ + private fun Flow>.collectAsState(): State> { + val state = mutableStateOf(emptyList()) + viewModelScope.launch { + collect { state.value = it } + } + return state + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt index cd9fe14807a..76ebb678529 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt @@ -80,7 +80,6 @@ class GraphWidget : BaseWidgetProvider() { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) } - //TODO val appWidgetManager = AppWidgetManager.getInstance(context) val options = appWidgetManager.getAppWidgetOptions(appWidgetId) val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) @@ -99,11 +98,10 @@ class GraphWidget : BaseWidgetProvider() { context.resources.displayMetrics ).toInt() - val useDynamicColors = widget?.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable() val views = RemoteViews(context.packageName, if (useDynamicColors) R.layout.widget_graph_wrapper_dynamiccolor else R.layout.widget_graph_wrapper_default) .apply { - if (widget != null) { + if (widget != null && historicData.histories?.isNotEmpty() == true) { val serverId = widget.serverId val entityId: String = widget.entityId val attributeIds: String? = widget.attributeIds @@ -122,12 +120,16 @@ class GraphWidget : BaseWidgetProvider() { // Content setViewVisibility( - R.id.widgetTextLayout, + R.id.chartImageView, View.VISIBLE ) setViewVisibility( R.id.widgetProgressBar, - View.INVISIBLE + View.GONE + ) + setViewVisibility( + R.id.widgetStaticError, + View.GONE ) val resolvedText = resolveTextToShow( context, @@ -149,7 +151,8 @@ class GraphWidget : BaseWidgetProvider() { if (resolvedText.exception) View.VISIBLE else View.GONE ) setImageViewBitmap( - R.id.chartImageView, createLineChart( + R.id.chartImageView, + createLineChart( context = context, label = label ?: entityId, entries = @@ -164,19 +167,45 @@ class GraphWidget : BaseWidgetProvider() { R.id.chartImageView, if (!resolvedText.exception) View.VISIBLE else View.GONE ) - setOnClickPendingIntent( - R.id.widgetTextLayout, - PendingIntent.getBroadcast( - context, - appWidgetId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + } else if (widget != null && historicData.histories?.isNotEmpty() == false) { + // Content + setViewVisibility( + R.id.chartImageView, + View.GONE + ) + setViewVisibility( + R.id.widgetProgressBar, + View.VISIBLE + ) + setViewVisibility( + R.id.widgetStaticError, + View.GONE ) } else { - setTextViewText(R.id.widgetText, "") - setTextViewText(R.id.widgetLabel, "") + // Content + setViewVisibility( + R.id.chartImageView, + View.GONE + ) + setViewVisibility( + R.id.widgetProgressBar, + View.GONE + ) + setViewVisibility( + R.id.widgetStaticError, + View.VISIBLE + ) } + + setOnClickPendingIntent( + R.id.widgetTextLayout, + PendingIntent.getBroadcast( + context, + appWidgetId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) } return views @@ -196,7 +225,6 @@ class GraphWidget : BaseWidgetProvider() { private fun createLineChart(context: Context, label: String, entries: List, width: Int, height: Int): LineChart { val lineChart = LineChart(context).apply { - setBackgroundColor(Color.WHITE) setDrawBorders(false) @@ -213,9 +241,9 @@ class GraphWidget : BaseWidgetProvider() { } axisLeft.apply { - setDrawGridLines(true) // Remove grid lines - textColor = Color.DKGRAY // Dark gray for less contrast - textSize = 12f // Slightly increase text size + setDrawGridLines(true) + textColor = Color.DKGRAY + textSize = 12F } axisRight.apply { @@ -237,19 +265,17 @@ class GraphWidget : BaseWidgetProvider() { description.isEnabled = false } - val mainGraphColor = context.resources.getColor(io.homeassistant.companion.android.common.R.color.colorPrimary) + val mainGraphColor = ContextCompat.getColor(context, commonR.color.colorPrimary) - - // Create LineDataSet with smooth blue color val dataSet = LineDataSet(entries, label).apply { color = mainGraphColor lineWidth = 2F circleRadius = 1F setDrawCircleHole(false) setCircleColor(mainGraphColor) - mode = LineDataSet.Mode.CUBIC_BEZIER // For smooth line - setDrawCircles(true) // Remove circles on data points - setDrawValues(false) // Remove value labels + mode = LineDataSet.Mode.CUBIC_BEZIER + setDrawCircles(true) + setDrawValues(false) } lineChart.data = LineData(dataSet) @@ -259,7 +285,6 @@ class GraphWidget : BaseWidgetProvider() { return lineChart } - private class TimeValueFormatter(private val timestamps: List) : ValueFormatter() { override fun getFormattedValue(value: Float): String { val index = value.toInt() @@ -276,7 +301,6 @@ class GraphWidget : BaseWidgetProvider() { } } - override suspend fun getAllWidgetIdsWithEntities(context: Context): Map>> = graphWidgetDao.getAll().associate { it.id to (it.serverId to listOf(it.entityId)) } @@ -384,7 +408,6 @@ class GraphWidget : BaseWidgetProvider() { override suspend fun onEntityStateChanged(context: Context, appWidgetId: Int, entity: Entity<*>) { widgetScope?.launch { - // Clean up old entries before updating the widget val oneHourInMillis = 60 * 60 * 1000L // 1 hour in milliseconds, this can be provided by UI graphWidgetDao.deleteEntriesOlderThan(appWidgetId, oneHourInMillis) diff --git a/app/src/main/res/drawable/widget_example_graph.png b/app/src/main/res/drawable/widget_example_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..4a0cf5a8b37c522ad5795d9bfff16be22705b135 GIT binary patch literal 17577 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�x=C7I1i4utceXR!6eW1If{t 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 afd3B~K-YmeB%FKz0000 + android:visibility="visible" /> - + android:gravity="center" + android:orientation="vertical" + android:visibility="gone"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c188bee7c9..b8b94597448 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ Historical Graph + We are retrieving some data, please came back later + To see historical data on graph \ No newline at end of file diff --git a/app/src/main/res/xml/graph_widget_info.xml b/app/src/main/res/xml/graph_widget_info.xml index 6a04cb5d495..c2305e7fa26 100644 --- a/app/src/main/res/xml/graph_widget_info.xml +++ b/app/src/main/res/xml/graph_widget_info.xml @@ -1,18 +1,18 @@ \ No newline at end of file + android:widgetFeatures="reconfigurable" /> diff --git a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json index a7b5c731517..4afc978faed 100644 --- a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 48, - "identityHash": "74986bc349d97b572c9acdaa1cb7f26d", + "identityHash": "7c947e581137cf86c2a89699b6289ff6", "entities": [ { "tableName": "sensor_attributes", @@ -664,6 +664,15 @@ ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_graph_widget_history_sent_state` ON `${TABLE_NAME}` (`sent_state`)" + }, + { + "name": "index_graph_widget_history_graph_widget_id", + "unique": false, + "columnNames": [ + "graph_widget_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_graph_widget_history_graph_widget_id` ON `${TABLE_NAME}` (`graph_widget_id`)" } ], "foreignKeys": [ @@ -1271,7 +1280,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '74986bc349d97b572c9acdaa1cb7f26d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7c947e581137cf86c2a89699b6289ff6')" ] } } \ No newline at end of file diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt index 165cc5daa39..b37f4d3a3e9 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import kotlinx.coroutines.flow.Flow @Dao @@ -12,6 +13,7 @@ interface GraphWidgetDao : WidgetDao { @Query("SELECT * FROM graph_widget WHERE id = :id") fun get(id: Int): GraphWidgetEntity? + @Transaction @Query("SELECT * FROM graph_widget WHERE id = :id") suspend fun getWithHistories(id: Int): GraphWidgetWithHistories? @@ -43,5 +45,4 @@ interface GraphWidgetDao : WidgetDao { @Query("DELETE FROM graph_widget_history WHERE graph_widget_id = :appWidgetId AND sent_state < :cutoffTime") suspend fun deleteEntriesOlderThan(appWidgetId: Int, cutoffTime: Long) - } diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt index 9c5d73024d5..8d388965e4c 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt @@ -17,13 +17,16 @@ import androidx.room.Index onDelete = ForeignKey.CASCADE ) ], - indices = [Index("sent_state")] + indices = [ + Index("sent_state"), + Index("graph_widget_id") + ] ) data class GraphWidgetHistoryEntity( @ColumnInfo(name = "entity_id") val entityId: String, @ColumnInfo(name = "graph_widget_id") - val graphWidgetId: Int, // This should match the type of GraphWidgetEntity's id + val graphWidgetId: Int, @ColumnInfo(name = "state") val state: String, @ColumnInfo(name = "sent_state") From 50c3a9be8de15d54b0c8c86220667f99e84470d1 Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Mon, 2 Sep 2024 02:29:01 +0200 Subject: [PATCH 03/57] Fix Lint Database Improvements --- .../android/HomeAssistantApplication.kt | 4 +- .../widgets/ManageWidgetsViewModel.kt | 150 +++++++++--------- .../android/widgets/graph/GraphWidget.kt | 85 ++++++---- .../graph/GraphWidgetConfigureActivity.kt | 4 +- .../res/drawable/widget_example_graph.png | Bin 0 -> 17577 bytes app/src/main/res/layout/widget_graph.xml | 42 +++-- app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/graph_widget_info.xml | 22 +-- .../48.json | 13 +- .../android/database/widget/GraphWidgetDao.kt | 3 +- .../widget/GraphWidgetHistoryEntity.kt | 7 +- settings.gradle.kts | 1 - 12 files changed, 193 insertions(+), 140 deletions(-) create mode 100644 app/src/main/res/drawable/widget_example_graph.png diff --git a/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt b/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt index b89947184d0..16f520f38bf 100644 --- a/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt +++ b/app/src/main/java/io/homeassistant/companion/android/HomeAssistantApplication.kt @@ -27,12 +27,12 @@ import io.homeassistant.companion.android.widgets.entity.EntityWidget import io.homeassistant.companion.android.widgets.graph.GraphWidget import io.homeassistant.companion.android.widgets.mediaplayer.MediaPlayerControlsWidget import io.homeassistant.companion.android.widgets.template.TemplateWidget +import javax.inject.Inject +import javax.inject.Named import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Named @HiltAndroidApp open class HomeAssistantApplication : Application() { diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt index 5435f44fb18..911475681bd 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt @@ -1,75 +1,75 @@ -package io.homeassistant.companion.android.settings.widgets - -import android.app.Application -import android.appwidget.AppWidgetManager -import android.os.Build -import android.os.RemoteException -import android.util.Log -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.core.content.getSystemService -import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import io.homeassistant.companion.android.database.widget.ButtonWidgetDao -import io.homeassistant.companion.android.database.widget.CameraWidgetDao -import io.homeassistant.companion.android.database.widget.GraphWidgetDao -import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao -import io.homeassistant.companion.android.database.widget.StaticWidgetDao -import io.homeassistant.companion.android.database.widget.TemplateWidgetDao -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch -import javax.inject.Inject - -@HiltViewModel -class ManageWidgetsViewModel @Inject constructor( - buttonWidgetDao: ButtonWidgetDao, - cameraWidgetDao: CameraWidgetDao, - staticWidgetDao: StaticWidgetDao, - graphWidgetDao: GraphWidgetDao, - mediaPlayerControlsWidgetDao: MediaPlayerControlsWidgetDao, - templateWidgetDao: TemplateWidgetDao, - application: Application -) : AndroidViewModel(application) { - companion object { - private const val TAG = "ManageWidgetsViewModel" - - const val CONFIGURE_REQUEST_LAUNCHER = - "io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER" - } - - val buttonWidgetList = buttonWidgetDao.getAllFlow().collectAsState() - val cameraWidgetList = cameraWidgetDao.getAllFlow().collectAsState() - val staticWidgetList = staticWidgetDao.getAllFlow().collectAsState() - val graphWidgetList = graphWidgetDao.getAllFlow().collectAsState() - val mediaWidgetList = mediaPlayerControlsWidgetDao.getAllFlow().collectAsState() - val templateWidgetList = templateWidgetDao.getAllFlow().collectAsState() - val supportsAddingWidgets: Boolean - - init { - supportsAddingWidgets = checkSupportsAddingWidgets() - } - - private fun checkSupportsAddingWidgets(): Boolean { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val appWidgetManager = getApplication().getSystemService() - try { - return appWidgetManager?.isRequestPinAppWidgetSupported ?: false - } catch (e: RemoteException) { - Log.e(TAG, "Unable to read isRequestPinAppWidgetSupported", e) - } - } - return false - } - - /** - * Convert a Flow into a State object that updates until the view model is cleared. - */ - private fun Flow>.collectAsState(): State> { - val state = mutableStateOf(emptyList()) - viewModelScope.launch { - collect { state.value = it } - } - return state - } -} +package io.homeassistant.companion.android.settings.widgets + +import android.app.Application +import android.appwidget.AppWidgetManager +import android.os.Build +import android.os.RemoteException +import android.util.Log +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.core.content.getSystemService +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.homeassistant.companion.android.database.widget.ButtonWidgetDao +import io.homeassistant.companion.android.database.widget.CameraWidgetDao +import io.homeassistant.companion.android.database.widget.GraphWidgetDao +import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao +import io.homeassistant.companion.android.database.widget.StaticWidgetDao +import io.homeassistant.companion.android.database.widget.TemplateWidgetDao +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +@HiltViewModel +class ManageWidgetsViewModel @Inject constructor( + buttonWidgetDao: ButtonWidgetDao, + cameraWidgetDao: CameraWidgetDao, + staticWidgetDao: StaticWidgetDao, + graphWidgetDao: GraphWidgetDao, + mediaPlayerControlsWidgetDao: MediaPlayerControlsWidgetDao, + templateWidgetDao: TemplateWidgetDao, + application: Application +) : AndroidViewModel(application) { + companion object { + private const val TAG = "ManageWidgetsViewModel" + + const val CONFIGURE_REQUEST_LAUNCHER = + "io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel.CONFIGURE_REQUEST_LAUNCHER" + } + + val buttonWidgetList = buttonWidgetDao.getAllFlow().collectAsState() + val cameraWidgetList = cameraWidgetDao.getAllFlow().collectAsState() + val staticWidgetList = staticWidgetDao.getAllFlow().collectAsState() + val graphWidgetList = graphWidgetDao.getAllFlow().collectAsState() + val mediaWidgetList = mediaPlayerControlsWidgetDao.getAllFlow().collectAsState() + val templateWidgetList = templateWidgetDao.getAllFlow().collectAsState() + val supportsAddingWidgets: Boolean + + init { + supportsAddingWidgets = checkSupportsAddingWidgets() + } + + private fun checkSupportsAddingWidgets(): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val appWidgetManager = getApplication().getSystemService() + try { + return appWidgetManager?.isRequestPinAppWidgetSupported ?: false + } catch (e: RemoteException) { + Log.e(TAG, "Unable to read isRequestPinAppWidgetSupported", e) + } + } + return false + } + + /** + * Convert a Flow into a State object that updates until the view model is cleared. + */ + private fun Flow>.collectAsState(): State> { + val state = mutableStateOf(emptyList()) + viewModelScope.launch { + collect { state.value = it } + } + return state + } +} diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt index cd9fe14807a..da63c24ef9c 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt @@ -27,6 +27,7 @@ import com.github.mikephil.charting.formatter.ValueFormatter import com.google.android.material.color.DynamicColors import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.R +import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.canSupportPrecision import io.homeassistant.companion.android.common.data.integration.friendlyState @@ -39,9 +40,8 @@ import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.database.widget.WidgetTapAction import io.homeassistant.companion.android.util.getAttribute import io.homeassistant.companion.android.widgets.BaseWidgetProvider -import kotlinx.coroutines.launch import javax.inject.Inject -import io.homeassistant.companion.android.common.R as commonR +import kotlinx.coroutines.launch @AndroidEntryPoint class GraphWidget : BaseWidgetProvider() { @@ -80,7 +80,6 @@ class GraphWidget : BaseWidgetProvider() { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) } - //TODO val appWidgetManager = AppWidgetManager.getInstance(context) val options = appWidgetManager.getAppWidgetOptions(appWidgetId) val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) @@ -99,11 +98,10 @@ class GraphWidget : BaseWidgetProvider() { context.resources.displayMetrics ).toInt() - val useDynamicColors = widget?.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable() val views = RemoteViews(context.packageName, if (useDynamicColors) R.layout.widget_graph_wrapper_dynamiccolor else R.layout.widget_graph_wrapper_default) .apply { - if (widget != null) { + if (widget != null && historicData.histories?.isNotEmpty() == true) { val serverId = widget.serverId val entityId: String = widget.entityId val attributeIds: String? = widget.attributeIds @@ -122,12 +120,16 @@ class GraphWidget : BaseWidgetProvider() { // Content setViewVisibility( - R.id.widgetTextLayout, + R.id.chartImageView, View.VISIBLE ) setViewVisibility( R.id.widgetProgressBar, - View.INVISIBLE + View.GONE + ) + setViewVisibility( + R.id.widgetStaticError, + View.GONE ) val resolvedText = resolveTextToShow( context, @@ -149,7 +151,8 @@ class GraphWidget : BaseWidgetProvider() { if (resolvedText.exception) View.VISIBLE else View.GONE ) setImageViewBitmap( - R.id.chartImageView, createLineChart( + R.id.chartImageView, + createLineChart( context = context, label = label ?: entityId, entries = @@ -164,19 +167,45 @@ class GraphWidget : BaseWidgetProvider() { R.id.chartImageView, if (!resolvedText.exception) View.VISIBLE else View.GONE ) - setOnClickPendingIntent( - R.id.widgetTextLayout, - PendingIntent.getBroadcast( - context, - appWidgetId, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) + } else if (widget != null && historicData.histories?.isNotEmpty() == false) { + // Content + setViewVisibility( + R.id.chartImageView, + View.GONE + ) + setViewVisibility( + R.id.widgetProgressBar, + View.VISIBLE + ) + setViewVisibility( + R.id.widgetStaticError, + View.GONE ) } else { - setTextViewText(R.id.widgetText, "") - setTextViewText(R.id.widgetLabel, "") + // Content + setViewVisibility( + R.id.chartImageView, + View.GONE + ) + setViewVisibility( + R.id.widgetProgressBar, + View.GONE + ) + setViewVisibility( + R.id.widgetStaticError, + View.VISIBLE + ) } + + setOnClickPendingIntent( + R.id.widgetTextLayout, + PendingIntent.getBroadcast( + context, + appWidgetId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + ) } return views @@ -196,7 +225,6 @@ class GraphWidget : BaseWidgetProvider() { private fun createLineChart(context: Context, label: String, entries: List, width: Int, height: Int): LineChart { val lineChart = LineChart(context).apply { - setBackgroundColor(Color.WHITE) setDrawBorders(false) @@ -213,9 +241,9 @@ class GraphWidget : BaseWidgetProvider() { } axisLeft.apply { - setDrawGridLines(true) // Remove grid lines - textColor = Color.DKGRAY // Dark gray for less contrast - textSize = 12f // Slightly increase text size + setDrawGridLines(true) + textColor = Color.DKGRAY + textSize = 12F } axisRight.apply { @@ -237,19 +265,17 @@ class GraphWidget : BaseWidgetProvider() { description.isEnabled = false } - val mainGraphColor = context.resources.getColor(io.homeassistant.companion.android.common.R.color.colorPrimary) - + val mainGraphColor = ContextCompat.getColor(context, commonR.color.colorPrimary) - // Create LineDataSet with smooth blue color val dataSet = LineDataSet(entries, label).apply { color = mainGraphColor lineWidth = 2F circleRadius = 1F setDrawCircleHole(false) setCircleColor(mainGraphColor) - mode = LineDataSet.Mode.CUBIC_BEZIER // For smooth line - setDrawCircles(true) // Remove circles on data points - setDrawValues(false) // Remove value labels + mode = LineDataSet.Mode.CUBIC_BEZIER + setDrawCircles(true) + setDrawValues(false) } lineChart.data = LineData(dataSet) @@ -259,7 +285,6 @@ class GraphWidget : BaseWidgetProvider() { return lineChart } - private class TimeValueFormatter(private val timestamps: List) : ValueFormatter() { override fun getFormattedValue(value: Float): String { val index = value.toInt() @@ -276,7 +301,6 @@ class GraphWidget : BaseWidgetProvider() { } } - override suspend fun getAllWidgetIdsWithEntities(context: Context): Map>> = graphWidgetDao.getAll().associate { it.id to (it.serverId to listOf(it.entityId)) } @@ -384,7 +408,6 @@ class GraphWidget : BaseWidgetProvider() { override suspend fun onEntityStateChanged(context: Context, appWidgetId: Int, entity: Entity<*>) { widgetScope?.launch { - // Clean up old entries before updating the widget val oneHourInMillis = 60 * 60 * 1000L // 1 hour in milliseconds, this can be provided by UI graphWidgetDao.deleteEntriesOlderThan(appWidgetId, oneHourInMillis) diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt index 47e99d07106..51206e73567 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt @@ -24,6 +24,7 @@ import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.google.android.material.color.DynamicColors import dagger.hilt.android.AndroidEntryPoint +import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.EntityExt import io.homeassistant.companion.android.common.data.integration.domain @@ -37,10 +38,9 @@ import io.homeassistant.companion.android.util.getHexForColor import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.BaseWidgetProvider import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter +import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import javax.inject.Inject -import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { diff --git a/app/src/main/res/drawable/widget_example_graph.png b/app/src/main/res/drawable/widget_example_graph.png new file mode 100644 index 0000000000000000000000000000000000000000..4a0cf5a8b37c522ad5795d9bfff16be22705b135 GIT binary patch literal 17577 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�x=C7I1i4utceXR!6eW1If{t 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 afd3B~K-YmeB%FKz0000 + android:visibility="visible" /> - + android:gravity="center" + android:orientation="vertical" + android:visibility="gone"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0c188bee7c9..b8b94597448 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ Historical Graph + We are retrieving some data, please came back later + To see historical data on graph \ No newline at end of file diff --git a/app/src/main/res/xml/graph_widget_info.xml b/app/src/main/res/xml/graph_widget_info.xml index 6a04cb5d495..c2305e7fa26 100644 --- a/app/src/main/res/xml/graph_widget_info.xml +++ b/app/src/main/res/xml/graph_widget_info.xml @@ -1,18 +1,18 @@ \ No newline at end of file + android:widgetFeatures="reconfigurable" /> diff --git a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json index a7b5c731517..4afc978faed 100644 --- a/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json +++ b/common/schemas/io.homeassistant.companion.android.database.AppDatabase/48.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 48, - "identityHash": "74986bc349d97b572c9acdaa1cb7f26d", + "identityHash": "7c947e581137cf86c2a89699b6289ff6", "entities": [ { "tableName": "sensor_attributes", @@ -664,6 +664,15 @@ ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_graph_widget_history_sent_state` ON `${TABLE_NAME}` (`sent_state`)" + }, + { + "name": "index_graph_widget_history_graph_widget_id", + "unique": false, + "columnNames": [ + "graph_widget_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_graph_widget_history_graph_widget_id` ON `${TABLE_NAME}` (`graph_widget_id`)" } ], "foreignKeys": [ @@ -1271,7 +1280,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '74986bc349d97b572c9acdaa1cb7f26d')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7c947e581137cf86c2a89699b6289ff6')" ] } } \ No newline at end of file diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt index 165cc5daa39..b37f4d3a3e9 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import kotlinx.coroutines.flow.Flow @Dao @@ -12,6 +13,7 @@ interface GraphWidgetDao : WidgetDao { @Query("SELECT * FROM graph_widget WHERE id = :id") fun get(id: Int): GraphWidgetEntity? + @Transaction @Query("SELECT * FROM graph_widget WHERE id = :id") suspend fun getWithHistories(id: Int): GraphWidgetWithHistories? @@ -43,5 +45,4 @@ interface GraphWidgetDao : WidgetDao { @Query("DELETE FROM graph_widget_history WHERE graph_widget_id = :appWidgetId AND sent_state < :cutoffTime") suspend fun deleteEntriesOlderThan(appWidgetId: Int, cutoffTime: Long) - } diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt index 9c5d73024d5..8d388965e4c 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt @@ -17,13 +17,16 @@ import androidx.room.Index onDelete = ForeignKey.CASCADE ) ], - indices = [Index("sent_state")] + indices = [ + Index("sent_state"), + Index("graph_widget_id") + ] ) data class GraphWidgetHistoryEntity( @ColumnInfo(name = "entity_id") val entityId: String, @ColumnInfo(name = "graph_widget_id") - val graphWidgetId: Int, // This should match the type of GraphWidgetEntity's id + val graphWidgetId: Int, @ColumnInfo(name = "state") val state: String, @ColumnInfo(name = "sent_state") diff --git a/settings.gradle.kts b/settings.gradle.kts index 37307a80c85..e274482110a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -29,6 +29,5 @@ dependencyResolutionManagement { mavenCentral() google() maven("https://jitpack.io") - maven("https://plugins.jetbrains.com/plugin/10766-database-debugger") } } From 9da3c3b96b5d9c0eee8178084b42807c5cb553fd Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Mon, 2 Sep 2024 02:41:12 +0200 Subject: [PATCH 04/57] Fix Lint Database Improvements --- .../homeassistant/companion/android/database/AppDatabase.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt index 2d37aca4011..92e9c77d19c 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt @@ -30,6 +30,7 @@ import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.IntegrationRepository import io.homeassistant.companion.android.common.util.CHANNEL_DATABASE import io.homeassistant.companion.android.database.authentication.Authentication @@ -75,9 +76,8 @@ import io.homeassistant.companion.android.database.widget.TemplateWidgetDao import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundTypeConverter import io.homeassistant.companion.android.database.widget.WidgetTapActionConverter -import kotlinx.coroutines.runBlocking import java.util.UUID -import io.homeassistant.companion.android.common.R as commonR +import kotlinx.coroutines.runBlocking @Database( entities = [ From 46a6f505efb52d7dd43f64176ba168828d51383d Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Mon, 2 Sep 2024 03:02:14 +0200 Subject: [PATCH 05/57] Adds dependencies to compile --- automotive/build.gradle.kts | 1 + wear/build.gradle.kts | 1 + 2 files changed, 2 insertions(+) diff --git a/automotive/build.gradle.kts b/automotive/build.gradle.kts index 291a57b2a4b..a5193ab27f9 100644 --- a/automotive/build.gradle.kts +++ b/automotive/build.gradle.kts @@ -147,6 +147,7 @@ android { dependencies { implementation(project(":common")) + implementation(libs.mpgraph) coreLibraryDesugaring(libs.tools.desugar.jdk) diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 12da17a7fbe..872498542f6 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -67,6 +67,7 @@ android { dependencies { implementation(project(":common")) + implementation(libs.mpgraph) coreLibraryDesugaring(libs.tools.desugar.jdk) From 24937fab0246367665cad7b68972188f4131aaf2 Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Tue, 3 Sep 2024 19:43:05 +0200 Subject: [PATCH 06/57] Resolves some comments in PR --- .../android/settings/widgets/ManageWidgetsViewModel.kt | 2 +- .../android/settings/widgets/views/ManageWidgetsView.kt | 2 +- .../companion/android/widgets/graph/GraphWidget.kt | 3 +-- app/src/main/res/values/strings.xml | 2 +- .../io/homeassistant/companion/android/database/AppDatabase.kt | 3 +-- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt index 911475681bd..80462bb9409 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt @@ -72,4 +72,4 @@ class ManageWidgetsViewModel @Inject constructor( } return state } -} +} \ No newline at end of file diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt index 311b58861d2..a985e16a59b 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/views/ManageWidgetsView.kt @@ -114,7 +114,7 @@ fun ManageWidgetsView( ) { if (viewModel.buttonWidgetList.value.isEmpty() && viewModel.staticWidgetList.value.isEmpty() && viewModel.mediaWidgetList.value.isEmpty() && viewModel.templateWidgetList.value.isEmpty() && - viewModel.templateWidgetList.value.isEmpty() && viewModel.graphWidgetList.value.isEmpty() + viewModel.cameraWidgetList.value.isEmpty() && viewModel.graphWidgetList.value.isEmpty() ) { item { EmptyState( diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt index da63c24ef9c..e01dd044060 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt @@ -101,7 +101,7 @@ class GraphWidget : BaseWidgetProvider() { val useDynamicColors = widget?.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable() val views = RemoteViews(context.packageName, if (useDynamicColors) R.layout.widget_graph_wrapper_dynamiccolor else R.layout.widget_graph_wrapper_default) .apply { - if (widget != null && historicData.histories?.isNotEmpty() == true) { + if (widget != null && (historicData.histories?.size ?: 0) >= 2) { val serverId = widget.serverId val entityId: String = widget.entityId val attributeIds: String? = widget.attributeIds @@ -213,7 +213,6 @@ class GraphWidget : BaseWidgetProvider() { private fun createEntriesFromHistoricData(historicData: GraphWidgetWithHistories): List { val entries = mutableListOf() - entries.add(Entry(0F, historicData.graphWidget.lastUpdate.toFloat())) historicData.getOrderedHistories( startTime = System.currentTimeMillis() - (60 * 60 * 3000), endTime = System.currentTimeMillis() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8b94597448..c4a9e9cd02e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ Historical Graph - We are retrieving some data, please came back later + We are retrieving some data… To see historical data on graph \ No newline at end of file diff --git a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt index 92e9c77d19c..c52e196150d 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt @@ -125,8 +125,7 @@ import kotlinx.coroutines.runBlocking AutoMigration(from = 43, to = 44), AutoMigration(from = 44, to = 45), AutoMigration(from = 45, to = 46), - AutoMigration(from = 46, to = 47), - AutoMigration(from = 47, to = 48) + AutoMigration(from = 46, to = 47) ] ) @TypeConverters( From c55bec6bb8dfcc4a05fda17e14c81680e99f6715 Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Tue, 3 Sep 2024 21:37:53 +0200 Subject: [PATCH 07/57] Fix linting format again :) --- .../android/settings/widgets/ManageWidgetsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt index 80462bb9409..911475681bd 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt @@ -72,4 +72,4 @@ class ManageWidgetsViewModel @Inject constructor( } return state } -} \ No newline at end of file +} From e90737d9244842a25ddd5d32d1fdb53ec6f0ee96 Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Tue, 3 Sep 2024 21:38:53 +0200 Subject: [PATCH 08/57] Fix linting format again :) --- .../companion/android/widgets/graph/GraphWidget.kt | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt index e01dd044060..6dbfa3b1227 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt @@ -181,20 +181,6 @@ class GraphWidget : BaseWidgetProvider() { R.id.widgetStaticError, View.GONE ) - } else { - // Content - setViewVisibility( - R.id.chartImageView, - View.GONE - ) - setViewVisibility( - R.id.widgetProgressBar, - View.GONE - ) - setViewVisibility( - R.id.widgetStaticError, - View.VISIBLE - ) } setOnClickPendingIntent( From 1a6a28ffda681222dba9859b0781973294100969 Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Wed, 4 Sep 2024 00:28:25 +0200 Subject: [PATCH 09/57] Adds Test Step --- .github/workflows/pr.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 884d9a69855..e9340df04bd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -67,6 +67,34 @@ jobs: - name: Validate Lint run: ./gradlew lint + tests: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 17 + uses: actions/setup-java@v4.2.2 + with: + distribution: 'adopt' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Mock google-services.json + run: | + cp .github/mock-google-services.json app/google-services.json + cp .github/mock-google-services.json wear/google-services.json + cp .github/mock-google-services.json automotive/google-services.json + + - name: Unit Testing + run: | + ./gradlew test + pr_build: runs-on: ubuntu-latest permissions: From 8bd32e47d6de7d1af7b72bc8c947afd35fc02df6 Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Wed, 4 Sep 2024 00:48:22 +0200 Subject: [PATCH 10/57] Adds some structure to add some unit testing --- .github/workflows/pr.yml | 2 +- app/build.gradle.kts | 26 +++- .../widgets/ManageWidgetsViewModel.kt | 2 +- .../android/widgets/graph/GraphWidget.kt | 79 +++++++---- .../graph/GraphWidgetConfigureActivity.kt | 2 +- .../graph/GraphWidgetRepositoryImplTest.kt | 130 ++++++++++++++++++ .../android/common/data/BaseDaoRepository.kt | 18 +++ .../android/common/data/DataModule.kt | 6 +- .../android/common/data/RepositoryModule.kt | 18 +++ .../data/widgets/GraphWidgetRepository.kt | 17 +++ .../data/widgets/GraphWidgetRepositoryImpl.kt | 56 ++++++++ .../companion/android/database/AppDatabase.kt | 6 +- .../android/database/DatabaseModule.kt | 2 +- .../widget/{ => graph}/GraphWidgetDao.kt | 3 +- .../widget/{ => graph}/GraphWidgetEntity.kt | 6 +- .../{ => graph}/GraphWidgetHistoryEntity.kt | 2 +- .../{ => graph}/GraphWidgetWithHistories.kt | 2 +- gradle/libs.versions.toml | 8 ++ 18 files changed, 342 insertions(+), 43 deletions(-) create mode 100644 app/src/test/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/data/BaseDaoRepository.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/data/RepositoryModule.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepository.kt create mode 100644 common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepositoryImpl.kt rename common/src/main/java/io/homeassistant/companion/android/database/widget/{ => graph}/GraphWidgetDao.kt (92%) rename common/src/main/java/io/homeassistant/companion/android/database/widget/{ => graph}/GraphWidgetEntity.kt (75%) rename common/src/main/java/io/homeassistant/companion/android/database/widget/{ => graph}/GraphWidgetHistoryEntity.kt (93%) rename common/src/main/java/io/homeassistant/companion/android/database/widget/{ => graph}/GraphWidgetWithHistories.kt (91%) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e9340df04bd..cc341522eee 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -91,7 +91,7 @@ jobs: cp .github/mock-google-services.json wear/google-services.json cp .github/mock-google-services.json automotive/google-services.json - - name: Unit Testing + - name: Testing run: | ./gradlew test diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1f6c638a2a3..f042730182f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -102,11 +102,9 @@ android { testOptions { unitTests.isReturnDefaultValues = true - } - - tasks.withType().configureEach { - useJUnitPlatform { - includeEngines("spek2") + unitTests.isIncludeAndroidResources = true + unitTests.all { + it.enabled = true } } @@ -191,6 +189,24 @@ dependencies { implementation(libs.car.core) "fullImplementation"(libs.car.projected) + + testImplementation(libs.junit) + + testImplementation("org.mockito:mockito-core:5.5.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.1.0") + + // Robolectric dependencies + testImplementation(libs.robolectric) + testImplementation(libs.androidx.core) + testImplementation(libs.androidx.junit) + testImplementation("androidx.test:rules:1.5.0") + testImplementation("androidx.test:runner:1.5.2") + + // Add this for Mockito support if needed + testImplementation("org.mockito:mockito-core:4.11.0") + + // Optional: Add this if you're using Kotlin-specific testing libraries + testImplementation("org.jetbrains.kotlin:kotlin-test:1.9.0") } // Disable to fix memory leak and be compatible with the configuration cache. diff --git a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt index 911475681bd..f79d8b5588b 100755 --- a/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt +++ b/app/src/main/java/io/homeassistant/companion/android/settings/widgets/ManageWidgetsViewModel.kt @@ -13,10 +13,10 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.CameraWidgetDao -import io.homeassistant.companion.android.database.widget.GraphWidgetDao import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.StaticWidgetDao import io.homeassistant.companion.android.database.widget.TemplateWidgetDao +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt index 6dbfa3b1227..ea529f35a21 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt @@ -32,15 +32,16 @@ import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.canSupportPrecision import io.homeassistant.companion.android.common.data.integration.friendlyState import io.homeassistant.companion.android.common.data.integration.onEntityPressedWithoutState -import io.homeassistant.companion.android.database.widget.GraphWidgetDao -import io.homeassistant.companion.android.database.widget.GraphWidgetEntity -import io.homeassistant.companion.android.database.widget.GraphWidgetHistoryEntity -import io.homeassistant.companion.android.database.widget.GraphWidgetWithHistories +import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepository import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.database.widget.WidgetTapAction +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetEntity +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetHistoryEntity +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetWithHistories import io.homeassistant.companion.android.util.getAttribute import io.homeassistant.companion.android.widgets.BaseWidgetProvider import javax.inject.Inject +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @AndroidEntryPoint @@ -66,13 +67,13 @@ class GraphWidget : BaseWidgetProvider() { } @Inject - lateinit var graphWidgetDao: GraphWidgetDao + lateinit var graphWidgetRepository: GraphWidgetRepository override fun getWidgetProvider(context: Context): ComponentName = ComponentName(context, GraphWidget::class.java) override suspend fun getWidgetRemoteViews(context: Context, appWidgetId: Int, suggestedEntity: Entity>?): RemoteViews { - val historicData = graphWidgetDao.getWithHistories(appWidgetId) + val historicData = graphWidgetRepository.getGraphWidgetWithHistories(appWidgetId) val widget = historicData?.graphWidget val intent = Intent(context, GraphWidget::class.java).apply { @@ -155,10 +156,7 @@ class GraphWidget : BaseWidgetProvider() { createLineChart( context = context, label = label ?: entityId, - entries = - createEntriesFromHistoricData( - historicData = historicData - ), + entries = createEntriesFromHistoricData(historicData = historicData), width = width, height = height ).chartBitmap @@ -221,7 +219,6 @@ class GraphWidget : BaseWidgetProvider() { textSize = 12F granularity = 2F setAvoidFirstLastClipping(true) -// valueFormatter = TimeValueFormatter(entries.map { it.x }) DateUtils.getRelativeTimeSpanString( history.sentState) isAutoScaleMinMaxEnabled = true } @@ -287,7 +284,9 @@ class GraphWidget : BaseWidgetProvider() { } override suspend fun getAllWidgetIdsWithEntities(context: Context): Map>> = - graphWidgetDao.getAll().associate { it.id to (it.serverId to listOf(it.entityId)) } + graphWidgetRepository.getAllFlow() + .first() + .associate { it.id to (it.serverId to listOf(it.entityId)) } private suspend fun resolveTextToShow( context: Context, @@ -320,11 +319,24 @@ class GraphWidget : BaseWidgetProvider() { null } if (attributeIds == null) { - graphWidgetDao.updateWidgetLastUpdate( - appWidgetId, - entity?.friendlyState(context, entityOptions) ?: graphWidgetDao.get(appWidgetId)?.lastUpdate ?: "" + graphWidgetRepository.add( + GraphWidgetEntity( + appWidgetId, + serverId, + entityId.toString(), + null, + null, + 30F, + "", + "", + WidgetTapAction.REFRESH, + entity?.friendlyState(context, entityOptions) + ?: graphWidgetRepository.get(appWidgetId)?.lastUpdate ?: "", + WidgetBackgroundType.DAYNIGHT, + null + ) ) - return ResolvedText(graphWidgetDao.get(appWidgetId)?.lastUpdate, entityCaughtException) + return ResolvedText(graphWidgetRepository.get(appWidgetId)?.lastUpdate, entityCaughtException) } try { @@ -334,18 +346,33 @@ class GraphWidget : BaseWidgetProvider() { val lastUpdate = entity?.friendlyState(context, entityOptions).plus(if (attributeValues.isNotEmpty()) stateSeparator else "") .plus(attributeValues.joinToString(attributeSeparator)) - graphWidgetDao.updateWidgetLastUpdate(appWidgetId, lastUpdate) + graphWidgetRepository.add( + GraphWidgetEntity( + appWidgetId, + serverId, + entityId.toString(), + attributeIds, + null, + 30F, + stateSeparator, + attributeSeparator, + WidgetTapAction.REFRESH, + lastUpdate, + WidgetBackgroundType.DAYNIGHT, + null + ) + ) return ResolvedText(lastUpdate) } catch (e: Exception) { Log.e(TAG, "Unable to fetch entity state and attributes", e) } - return ResolvedText(graphWidgetDao.get(appWidgetId)?.lastUpdate, true) + return ResolvedText(graphWidgetRepository.get(appWidgetId)?.lastUpdate, true) } override fun saveEntityConfiguration(context: Context, extras: Bundle?, appWidgetId: Int) { if (extras == null) return - val serverId = if (extras.containsKey(EXTRA_SERVER_ID)) extras.getInt(EXTRA_SERVER_ID) else null + val serverId = extras.getInt(EXTRA_SERVER_ID) val entitySelection: String? = extras.getString(EXTRA_ENTITY_ID) val attributeSelection: ArrayList? = extras.getStringArrayList(EXTRA_ATTRIBUTE_IDS) val labelSelection: String? = extras.getString(EXTRA_LABEL) @@ -370,7 +397,7 @@ class GraphWidget : BaseWidgetProvider() { "entity id: " + entitySelection + System.lineSeparator() + "attribute: " + (attributeSelection ?: "N/A") ) - graphWidgetDao.add( + graphWidgetRepository.add( GraphWidgetEntity( appWidgetId, serverId, @@ -381,7 +408,7 @@ class GraphWidget : BaseWidgetProvider() { stateSeparatorSelection ?: "", attributeSeparatorSelection ?: "", tapActionSelection, - graphWidgetDao.get(appWidgetId)?.lastUpdate ?: "", + graphWidgetRepository.get(appWidgetId)?.lastUpdate ?: "", backgroundTypeSelection, textColorSelection ) @@ -395,9 +422,9 @@ class GraphWidget : BaseWidgetProvider() { widgetScope?.launch { // Clean up old entries before updating the widget val oneHourInMillis = 60 * 60 * 1000L // 1 hour in milliseconds, this can be provided by UI - graphWidgetDao.deleteEntriesOlderThan(appWidgetId, oneHourInMillis) + graphWidgetRepository.deleteEntriesOlderThan(appWidgetId, oneHourInMillis) - graphWidgetDao.add( + graphWidgetRepository.insertGraphWidgetHistory( GraphWidgetHistoryEntity( UUIDGenerator().generateId(entity.entityId + entity.lastChanged).toString(), appWidgetId, @@ -420,7 +447,7 @@ class GraphWidget : BaseWidgetProvider() { appWidgetManager.partiallyUpdateAppWidget(appWidgetId, loadingViews) var success = false - graphWidgetDao.get(appWidgetId)?.let { + graphWidgetRepository.get(appWidgetId)?.let { try { onEntityPressedWithoutState( it.entityId, @@ -444,14 +471,14 @@ class GraphWidget : BaseWidgetProvider() { override fun onReceive(context: Context, intent: Intent) { val appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1) super.onReceive(context, intent) - when (lastIntent) { + when (intent.action) { TOGGLE_ENTITY -> toggleEntity(context, appWidgetId) } } override fun onDeleted(context: Context, appWidgetIds: IntArray) { widgetScope?.launch { - graphWidgetDao.deleteAll(appWidgetIds) + graphWidgetRepository.deleteAll(appWidgetIds) appWidgetIds.forEach { removeSubscription(it) } } } diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt index 51206e73567..5df918f11cd 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt @@ -29,9 +29,9 @@ import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.EntityExt import io.homeassistant.companion.android.common.data.integration.domain import io.homeassistant.companion.android.common.data.integration.friendlyName -import io.homeassistant.companion.android.database.widget.GraphWidgetDao import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.database.widget.WidgetTapAction +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao import io.homeassistant.companion.android.databinding.WidgetGraphConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.util.getHexForColor diff --git a/app/src/test/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt b/app/src/test/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt new file mode 100644 index 00000000000..d6dc03ac5b7 --- /dev/null +++ b/app/src/test/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt @@ -0,0 +1,130 @@ +package io.homeassistant.companion.android.widgets.graph + +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.database.widget.WidgetTapAction +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetEntity +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetHistoryEntity +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetWithHistories +import io.homeassistant.companion.android.repository.GraphWidgetRepositoryImpl +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@RunWith(MockitoJUnitRunner::class) +class GraphWidgetRepositoryImplTest { + + private lateinit var graphWidgetDao: GraphWidgetDao + private lateinit var graphWidgetRepository: GraphWidgetRepositoryImpl + + @Before + fun setUp() { + graphWidgetDao = mock(GraphWidgetDao::class.java) + graphWidgetRepository = GraphWidgetRepositoryImpl(graphWidgetDao) + } + + @Test + fun `test getGraphWidget returns expected result`() = runBlocking { + val expectedEntity = GraphWidgetEntity(1, 1, "entityId", null, null, 30F, "", "", WidgetTapAction.REFRESH, "lastUpdate", WidgetBackgroundType.DAYNIGHT, null) + whenever(graphWidgetDao.get(1)).thenReturn(expectedEntity) + + val result = graphWidgetRepository.get(1) + + assertEquals(expectedEntity, result) + } + + @Test + fun `test getGraphWidgetWithHistories returns expected result`() = runBlocking { + val expectedHistories = GraphWidgetWithHistories(GraphWidgetEntity(1, 1, "entityId", null, null, 30F, "", "", WidgetTapAction.REFRESH, "lastUpdate", WidgetBackgroundType.DAYNIGHT, null), listOf()) + whenever(graphWidgetDao.getWithHistories(1)).thenReturn(expectedHistories) + + val result = graphWidgetRepository.getGraphWidgetWithHistories(1) + + assertEquals(expectedHistories, result) + } + + @Test + fun `test add GraphWidgetEntity`() = runBlocking { + val entity = GraphWidgetEntity(1, 1, "entityId", null, null, 30F, "", "", WidgetTapAction.REFRESH, "lastUpdate", WidgetBackgroundType.DAYNIGHT, null) + + graphWidgetRepository.add(entity) + + verify(graphWidgetDao).add(entity) + } + + @Test + fun `test delete GraphWidgetEntity by id`() = runBlocking { + val widgetId = 1 + + graphWidgetRepository.delete(widgetId) + + verify(graphWidgetDao).delete(widgetId) + } + + @Test + fun `test deleteAll GraphWidgetEntity by ids`() = runBlocking { + val widgetIds = intArrayOf(1, 2, 3) + + graphWidgetRepository.deleteAll(widgetIds) + + verify(graphWidgetDao).deleteAll(widgetIds) + } + + @Test + fun `test getAll returns expected result`() = runBlocking { + val expectedList = listOf(GraphWidgetEntity(1, 1, "entityId", null, null, 30F, "", "", WidgetTapAction.REFRESH, "lastUpdate", WidgetBackgroundType.DAYNIGHT, null)) + whenever(graphWidgetDao.getAll()).thenReturn(expectedList) + + val result = graphWidgetRepository.getAll() + + assertEquals(expectedList, result) + } + + @Test + fun `test getAllFlow returns expected result`() = runBlocking { + val expectedList = listOf(GraphWidgetEntity(1, 1, "entityId", null, null, 30F, "", "", WidgetTapAction.REFRESH, "lastUpdate", WidgetBackgroundType.DAYNIGHT, null)) + whenever(graphWidgetDao.getAllFlow()).thenReturn(flowOf(expectedList)) + + val result = graphWidgetRepository.getAllFlow().first() + + assertEquals(expectedList, result) + } + + @Test + fun `test updateWidgetLastUpdate`() = runBlocking { + val widgetId = 1 + val lastUpdate = "newUpdate" + + graphWidgetRepository.updateWidgetLastUpdate(widgetId, lastUpdate) + + verify(graphWidgetDao).updateWidgetLastUpdate(widgetId, lastUpdate) + } + + @Test + fun `test deleteEntriesOlderThan`() = runBlocking { + val widgetId = 1 + val cutoffTime = System.currentTimeMillis() + + graphWidgetRepository.deleteEntriesOlderThan(widgetId, cutoffTime) + + verify(graphWidgetDao).deleteEntriesOlderThan(widgetId, cutoffTime) + assert(verify(graphWidgetDao.get(widgetId)) == null) + } + + @Test + fun `test insertGraphWidgetHistory`() = runBlocking { + val historyEntity = GraphWidgetHistoryEntity("historyId", 1, "state", System.currentTimeMillis()) + + graphWidgetRepository.insertGraphWidgetHistory(historyEntity) + + verify(graphWidgetDao).add(historyEntity) + } +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/BaseDaoRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/BaseDaoRepository.kt new file mode 100644 index 00000000000..5cb29c02ec4 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/BaseDaoRepository.kt @@ -0,0 +1,18 @@ +package io.homeassistant.companion.android.common.data + +import kotlinx.coroutines.flow.Flow + +interface BaseDaoRepository { + + suspend fun get(id: Int): T? + + suspend fun add(entity: T) + + suspend fun delete(id: Int) + + suspend fun deleteAll(ids: IntArray) + + suspend fun getAll(): List + + fun getAllFlow(): Flow> +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/DataModule.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/DataModule.kt index 7bc7cfe3896..9ee051f2213 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/DataModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/DataModule.kt @@ -35,7 +35,11 @@ import javax.inject.Singleton import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient -@Module +@Module( + includes = [ + RepositoryModule::class + ] +) @InstallIn(SingletonComponent::class) abstract class DataModule { diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/RepositoryModule.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/RepositoryModule.kt new file mode 100644 index 00000000000..a8d3ffa8dee --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/RepositoryModule.kt @@ -0,0 +1,18 @@ +package io.homeassistant.companion.android.common.data + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepository +import io.homeassistant.companion.android.repository.GraphWidgetRepositoryImpl + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + abstract fun bindGraphWidgetRepository( + impl: GraphWidgetRepositoryImpl + ): GraphWidgetRepository +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepository.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepository.kt new file mode 100644 index 00000000000..ecd9077d673 --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepository.kt @@ -0,0 +1,17 @@ +package io.homeassistant.companion.android.common.data.widgets + +import io.homeassistant.companion.android.common.data.BaseDaoRepository +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetEntity +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetHistoryEntity +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetWithHistories + +interface GraphWidgetRepository : BaseDaoRepository { + + suspend fun getGraphWidgetWithHistories(id: Int): GraphWidgetWithHistories? + + suspend fun updateWidgetLastUpdate(widgetId: Int, lastUpdate: String) + + suspend fun deleteEntriesOlderThan(appWidgetId: Int, cutoffTime: Long) + + suspend fun insertGraphWidgetHistory(historyEntity: GraphWidgetHistoryEntity) +} diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepositoryImpl.kt new file mode 100644 index 00000000000..ac76bd4265c --- /dev/null +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepositoryImpl.kt @@ -0,0 +1,56 @@ +package io.homeassistant.companion.android.repository + +import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepository +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetEntity +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetHistoryEntity +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetWithHistories +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOn + +class GraphWidgetRepositoryImpl @Inject constructor( + private val graphWidgetDao: GraphWidgetDao +) : GraphWidgetRepository { + + override suspend fun get(id: Int): GraphWidgetEntity? { + return graphWidgetDao.get(id) + } + + override suspend fun getGraphWidgetWithHistories(id: Int): GraphWidgetWithHistories? { + return graphWidgetDao.getWithHistories(id) + } + + override suspend fun add(entity: GraphWidgetEntity) { + graphWidgetDao.add(entity) + } + + override suspend fun delete(id: Int) { + graphWidgetDao.delete(id) + } + + override suspend fun deleteAll(ids: IntArray) { + graphWidgetDao.deleteAll(ids) + } + + override suspend fun getAll(): List { + return graphWidgetDao.getAll() + } + + override fun getAllFlow(): Flow> { + return graphWidgetDao.getAllFlow().flowOn(Dispatchers.IO) + } + + override suspend fun updateWidgetLastUpdate(widgetId: Int, lastUpdate: String) { + graphWidgetDao.updateWidgetLastUpdate(widgetId, lastUpdate) + } + + override suspend fun deleteEntriesOlderThan(appWidgetId: Int, cutoffTime: Long) { + graphWidgetDao.deleteEntriesOlderThan(appWidgetId, cutoffTime) + } + + override suspend fun insertGraphWidgetHistory(historyEntity: GraphWidgetHistoryEntity) { + graphWidgetDao.add(historyEntity) + } +} diff --git a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt index c52e196150d..e7bb330a950 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/AppDatabase.kt @@ -65,9 +65,6 @@ import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.ButtonWidgetEntity import io.homeassistant.companion.android.database.widget.CameraWidgetDao import io.homeassistant.companion.android.database.widget.CameraWidgetEntity -import io.homeassistant.companion.android.database.widget.GraphWidgetDao -import io.homeassistant.companion.android.database.widget.GraphWidgetEntity -import io.homeassistant.companion.android.database.widget.GraphWidgetHistoryEntity import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetEntity import io.homeassistant.companion.android.database.widget.StaticWidgetDao @@ -76,6 +73,9 @@ import io.homeassistant.companion.android.database.widget.TemplateWidgetDao import io.homeassistant.companion.android.database.widget.TemplateWidgetEntity import io.homeassistant.companion.android.database.widget.WidgetBackgroundTypeConverter import io.homeassistant.companion.android.database.widget.WidgetTapActionConverter +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetEntity +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetHistoryEntity import java.util.UUID import kotlinx.coroutines.runBlocking diff --git a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt index 6023af8f222..56b7c23a466 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/DatabaseModule.kt @@ -19,10 +19,10 @@ import io.homeassistant.companion.android.database.wear.FavoriteCachesDao import io.homeassistant.companion.android.database.wear.FavoritesDao import io.homeassistant.companion.android.database.widget.ButtonWidgetDao import io.homeassistant.companion.android.database.widget.CameraWidgetDao -import io.homeassistant.companion.android.database.widget.GraphWidgetDao import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.StaticWidgetDao import io.homeassistant.companion.android.database.widget.TemplateWidgetDao +import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao import javax.inject.Singleton @Module diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetDao.kt similarity index 92% rename from common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt rename to common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetDao.kt index b37f4d3a3e9..e164e7cdabf 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetDao.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetDao.kt @@ -1,10 +1,11 @@ -package io.homeassistant.companion.android.database.widget +package io.homeassistant.companion.android.database.widget.graph import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import io.homeassistant.companion.android.database.widget.WidgetDao import kotlinx.coroutines.flow.Flow @Dao diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetEntity.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetEntity.kt similarity index 75% rename from common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetEntity.kt rename to common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetEntity.kt index a3a00f43c22..e0009c4b8e1 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetEntity.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetEntity.kt @@ -1,8 +1,12 @@ -package io.homeassistant.companion.android.database.widget +package io.homeassistant.companion.android.database.widget.graph import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import io.homeassistant.companion.android.database.widget.ThemeableWidgetEntity +import io.homeassistant.companion.android.database.widget.WidgetBackgroundType +import io.homeassistant.companion.android.database.widget.WidgetEntity +import io.homeassistant.companion.android.database.widget.WidgetTapAction @Entity(tableName = "graph_widget") data class GraphWidgetEntity( diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetHistoryEntity.kt similarity index 93% rename from common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt rename to common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetHistoryEntity.kt index 8d388965e4c..6b43fb71eb0 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetHistoryEntity.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetHistoryEntity.kt @@ -1,4 +1,4 @@ -package io.homeassistant.companion.android.database.widget +package io.homeassistant.companion.android.database.widget.graph import androidx.room.ColumnInfo import androidx.room.Entity diff --git a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetWithHistories.kt b/common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetWithHistories.kt similarity index 91% rename from common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetWithHistories.kt rename to common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetWithHistories.kt index 5dc319436b4..4e01ee7cf13 100644 --- a/common/src/main/java/io/homeassistant/companion/android/database/widget/GraphWidgetWithHistories.kt +++ b/common/src/main/java/io/homeassistant/companion/android/database/widget/graph/GraphWidgetWithHistories.kt @@ -1,4 +1,4 @@ -package io.homeassistant.companion.android.database.widget +package io.homeassistant.companion.android.database.widget.graph import androidx.room.Embedded import androidx.room.Relation diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3c692a3b497..84444d80852 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ community-material-typeface = "7.0.96.1-kotlin" compose-bom = "2024.06.00" connectClient = "1.1.0-alpha07" constraintlayout = "2.1.4" +core = "1.5.0" coreKtx = "1.13.1" core-splashscreen = "1.1.0-rc01" cronet-embedded = "119.6045.31" @@ -31,6 +32,8 @@ hilt = "2.52" iconics = "5.4.0" jackson-module-kotlin = "2.13.5" javaVersion = "11" +junit = "4.13.2" +junitVersion = "1.1.5" kotlinx-coroutines = "1.8.1" kotlin = "2.0.10" ksp = "2.0.10-1.0.24" @@ -51,6 +54,7 @@ preference-ktx = "1.2.1" recyclerview = "1.3.2" reorderable = "0.9.6" retrofit = "2.9.0" +robolectric = "4.10.3" room = "2.6.1" sentry-android = "7.14.0" tools-desugar-jdk-libs = "2.0.4" @@ -86,8 +90,10 @@ ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" } activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity-compose" } android-beacon-library = { module = "org.altbeacon:android-beacon-library", version.ref = "androidBeaconLibrary" } +androidx-core = { module = "androidx.test:core", version.ref = "core" } androidx-health-connect-client = { module = "androidx.health.connect:connect-client", version.ref = "connectClient" } androidx-health-services-client = { module = "androidx.health:health-services-client", version.ref = "healthServicesClient" } +androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } androidx-media = { module = "androidx.media:media", version.ref = "media" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } @@ -131,6 +137,7 @@ horologist-layout = { module = "com.google.android.horologist:horologist-compose iconics-compose = { module = "com.mikepenz:iconics-compose", version.ref = "iconics" } iconics-core = { module = "com.mikepenz:iconics-core", version.ref = "iconics" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson-module-kotlin" } +junit = { module = "junit:junit", version.ref = "junit" } kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } @@ -157,6 +164,7 @@ recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "r reorderable = { module = "org.burnoutcrew.composereorderable:reorderable", version.ref = "reorderable" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-converter-jackson = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentry-android" } tools-desugar-jdk = { module = "com.android.tools:desugar_jdk_libs", version.ref = "tools-desugar-jdk-libs" } wear = { module = "androidx.wear:wear", version.ref = "wear" } From 7964d28fb4e559721818ca4be844d21ed729b3ee Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Wed, 4 Sep 2024 01:55:43 +0200 Subject: [PATCH 11/57] Adds instrumentation tests --- .github/workflows/pr.yml | 15 +- app/build.gradle.kts | 26 ++-- .../graph/GraphWidgetRepositoryImplTest.kt | 131 ++++++++++++++++++ .../graph/GraphWidgetRepositoryImplTest.kt | 4 +- .../android/common/data/RepositoryModule.kt | 2 +- .../data/widgets/GraphWidgetRepositoryImpl.kt | 3 +- gradle/libs.versions.toml | 19 ++- 7 files changed, 179 insertions(+), 21 deletions(-) create mode 100644 app/src/androidTest/kotlin/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt rename app/src/test/{java => kotlin}/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt (97%) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index cc341522eee..82ab0d7712c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -91,9 +91,20 @@ jobs: cp .github/mock-google-services.json wear/google-services.json cp .github/mock-google-services.json automotive/google-services.json - - name: Testing + - name: Run Unit Tests + run: ./gradlew test --no-daemon --parallel + + - name: Display Test Results run: | - ./gradlew test + echo "Displaying test results..." + cat $(find . -name 'TEST-*.xml') | grep "() + db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) + .allowMainThreadQueries() + .build() + graphWidgetDao = db.graphWidgetDao() + graphWidgetRepository = GraphWidgetRepositoryImpl(graphWidgetDao) + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun testInsertAndGetGraphWidget() = runBlocking { + graphWidgetRepository.add(Companion.WIDGET_OBJECT) + + val retrievedWidget = graphWidgetRepository.get(1) + assertEquals(Companion.WIDGET_OBJECT, retrievedWidget) + } + + @Test + fun testUpdateWidgetLastUpdate() = runBlocking { + graphWidgetRepository.add(Companion.WIDGET_OBJECT) + + val newUpdate = "New Update" + graphWidgetRepository.updateWidgetLastUpdate(1, newUpdate) + + val updatedWidget = graphWidgetRepository.get(1) + assertEquals(newUpdate, updatedWidget?.lastUpdate) + } + + @Test + fun testInsertGraphWidgetHistory() = runBlocking { + graphWidgetRepository.add(Companion.WIDGET_OBJECT) + + val historyEntity = GraphWidgetHistoryEntity( + entityId = "history1", + graphWidgetId = 1, + state = "State1", + sentState = System.currentTimeMillis() + ) + + graphWidgetRepository.insertGraphWidgetHistory(historyEntity) + + val widgetHistories = graphWidgetDao.getWithHistories(1) + assertEquals(1, widgetHistories?.histories?.size) + assertEquals(historyEntity, widgetHistories?.histories?.get(0)) + } + + @Test + fun testDeleteEntriesOlderThan() = runBlocking { + graphWidgetRepository.add(Companion.WIDGET_OBJECT) + + val currentTime = System.currentTimeMillis() + val oldTime = currentTime - (60 * 60 * 1000) // 1 hour ago + + val historyEntity1 = GraphWidgetHistoryEntity("history1", 1, "State1", oldTime) + val historyEntity2 = GraphWidgetHistoryEntity("history2", 1, "State2", currentTime) + + graphWidgetRepository.insertGraphWidgetHistory(historyEntity1) + graphWidgetRepository.insertGraphWidgetHistory(historyEntity2) + + graphWidgetRepository.deleteEntriesOlderThan(1, currentTime) + + val widgetHistories = graphWidgetDao.getWithHistories(1) + assertEquals(1, widgetHistories?.histories?.size) + assertEquals(historyEntity2, widgetHistories?.histories?.get(0)) + } + + @Test + fun testSaveHistoricWithoutAppWidgetShouldThrowExceptionForeignKey() = runBlocking { + val historyEntity1 = GraphWidgetHistoryEntity("history1", 1, "State1", System.currentTimeMillis()) + + assertThrows(SQLiteConstraintException::class.java) { + runBlocking { + graphWidgetDao.add(historyEntity1) + } + } + } +} diff --git a/app/src/test/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt similarity index 97% rename from app/src/test/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt rename to app/src/test/kotlin/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt index d6dc03ac5b7..c24daed5598 100644 --- a/app/src/test/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt +++ b/app/src/test/kotlin/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt @@ -1,12 +1,12 @@ package io.homeassistant.companion.android.widgets.graph +import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepositoryImpl import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.database.widget.WidgetTapAction import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao import io.homeassistant.companion.android.database.widget.graph.GraphWidgetEntity import io.homeassistant.companion.android.database.widget.graph.GraphWidgetHistoryEntity import io.homeassistant.companion.android.database.widget.graph.GraphWidgetWithHistories -import io.homeassistant.companion.android.repository.GraphWidgetRepositoryImpl import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -116,7 +116,7 @@ class GraphWidgetRepositoryImplTest { graphWidgetRepository.deleteEntriesOlderThan(widgetId, cutoffTime) verify(graphWidgetDao).deleteEntriesOlderThan(widgetId, cutoffTime) - assert(verify(graphWidgetDao.get(widgetId)) == null) + assert(graphWidgetDao.get(widgetId) == null) } @Test diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/RepositoryModule.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/RepositoryModule.kt index a8d3ffa8dee..e75e729175f 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/RepositoryModule.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/RepositoryModule.kt @@ -5,7 +5,7 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepository -import io.homeassistant.companion.android.repository.GraphWidgetRepositoryImpl +import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepositoryImpl @Module @InstallIn(SingletonComponent::class) diff --git a/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepositoryImpl.kt b/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepositoryImpl.kt index ac76bd4265c..d0fd2f547a7 100644 --- a/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepositoryImpl.kt +++ b/common/src/main/java/io/homeassistant/companion/android/common/data/widgets/GraphWidgetRepositoryImpl.kt @@ -1,6 +1,5 @@ -package io.homeassistant.companion.android.repository +package io.homeassistant.companion.android.common.data.widgets -import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepository import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao import io.homeassistant.companion.android.database.widget.graph.GraphWidgetEntity import io.homeassistant.companion.android.database.widget.graph.GraphWidgetHistoryEntity diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84444d80852..ee6ed56935e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ coreKtx = "1.13.1" core-splashscreen = "1.1.0-rc01" cronet-embedded = "119.6045.31" emojiJava = "5.1.1" +espressoCore = "3.4.0" firebase-bom = "33.1.2" firebaseAppdistributionGradle = "5.0.0" fragment-ktx = "1.8.2" @@ -33,7 +34,8 @@ iconics = "5.4.0" jackson-module-kotlin = "2.13.5" javaVersion = "11" junit = "4.13.2" -junitVersion = "1.1.5" +junitVersion = "1.2.1" +kotlinTest = "1.9.0" kotlinx-coroutines = "1.8.1" kotlin = "2.0.10" ksp = "2.0.10-1.0.24" @@ -42,6 +44,10 @@ lifecycle = "2.8.4" material = "1.12.0" media = "1.7.0" media3 = "1.4.0" +mockitoAndroid = "3.11.2" +mockitoCore = "5.5.0" +mockitoInline = "3.11.2" +mockitoKotlin = "5.1.0" navigation-compose = "2.7.7" okhttp = "5.0.0-alpha.14" paging = "3.3.2" @@ -56,6 +62,8 @@ reorderable = "0.9.6" retrofit = "2.9.0" robolectric = "4.10.3" room = "2.6.1" +roomTesting = "2.4.1" +runner = "1.5.2" sentry-android = "7.14.0" tools-desugar-jdk-libs = "2.0.4" watchfaceComplicationsDataSourceKtx = "1.2.1" @@ -91,6 +99,7 @@ activity-compose = { module = "androidx.activity:activity-compose", version.ref activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity-compose" } android-beacon-library = { module = "org.altbeacon:android-beacon-library", version.ref = "androidBeaconLibrary" } androidx-core = { module = "androidx.test:core", version.ref = "core" } +androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espressoCore" } androidx-health-connect-client = { module = "androidx.health.connect:connect-client", version.ref = "connectClient" } androidx-health-services-client = { module = "androidx.health:health-services-client", version.ref = "healthServicesClient" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junitVersion" } @@ -102,6 +111,9 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = " androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "roomTesting" } +androidx-rules = { module = "androidx.test:rules", version.ref = "core" } +androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } androidx-watchface-complications-data-source-ktx = { module = "androidx.wear.watchface:watchface-complications-data-source-ktx", version.ref = "watchfaceComplicationsDataSourceKtx" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } @@ -138,6 +150,7 @@ iconics-compose = { module = "com.mikepenz:iconics-compose", version.ref = "icon iconics-core = { module = "com.mikepenz:iconics-core", version.ref = "iconics" } jackson-module-kotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson-module-kotlin" } junit = { module = "junit:junit", version.ref = "junit" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlinTest" } kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" } @@ -145,6 +158,10 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +mockito-android = { module = "org.mockito:mockito-android", version.ref = "mockitoAndroid" } +mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoCore" } +mockito-inline = { module = "org.mockito:mockito-inline", version.ref = "mockitoInline" } +mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" } navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } material = { module = "com.google.android.material:material", version.ref = "material" } From dce87319982fa61a872be19172ca21d998aff85b Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Wed, 4 Sep 2024 02:25:35 +0200 Subject: [PATCH 12/57] Nicer UI --- .../android/widgets/graph/GraphWidget.kt | 3 +- .../graph/GraphWidgetConfigureActivity.kt | 86 ++---------- .../res/layout/widget_graph_configure.xml | 126 +++--------------- app/src/main/res/values/strings.xml | 1 + 4 files changed, 29 insertions(+), 187 deletions(-) diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt index ea529f35a21..44a16bb828e 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt @@ -40,6 +40,7 @@ import io.homeassistant.companion.android.database.widget.graph.GraphWidgetHisto import io.homeassistant.companion.android.database.widget.graph.GraphWidgetWithHistories import io.homeassistant.companion.android.util.getAttribute import io.homeassistant.companion.android.widgets.BaseWidgetProvider +import io.homeassistant.companion.android.widgets.entity.EntityWidget.Companion.EXTRA_STATE_SEPARATOR import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -57,7 +58,7 @@ class GraphWidget : BaseWidgetProvider() { internal const val EXTRA_ATTRIBUTE_IDS = "EXTRA_ATTRIBUTE_IDS" internal const val EXTRA_LABEL = "EXTRA_LABEL" internal const val EXTRA_TEXT_SIZE = "EXTRA_TEXT_SIZE" - internal const val EXTRA_STATE_SEPARATOR = "EXTRA_STATE_SEPARATOR" + internal const val EXTRA_HOURS_TO_SAMPLE = "EXTRA_HOURS_TO_SAMPLE" internal const val EXTRA_ATTRIBUTE_SEPARATOR = "EXTRA_ATTRIBUTE_SEPARATOR" internal const val EXTRA_TAP_ACTION = "EXTRA_TAP_ACTION" internal const val EXTRA_BACKGROUND_TYPE = "EXTRA_BACKGROUND_TYPE" diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt index 5df918f11cd..abd69b93ca1 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt @@ -17,9 +17,7 @@ import android.widget.LinearLayout.VISIBLE import android.widget.MultiAutoCompleteTextView.CommaTokenizer import android.widget.Spinner import android.widget.Toast -import androidx.core.content.ContextCompat import androidx.core.content.getSystemService -import androidx.core.graphics.toColorInt import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.google.android.material.color.DynamicColors @@ -29,12 +27,10 @@ import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.EntityExt import io.homeassistant.companion.android.common.data.integration.domain import io.homeassistant.companion.android.common.data.integration.friendlyName -import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.database.widget.WidgetTapAction import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao import io.homeassistant.companion.android.databinding.WidgetGraphConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel -import io.homeassistant.companion.android.util.getHexForColor import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.BaseWidgetProvider import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter @@ -140,13 +136,10 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { if (DynamicColors.isDynamicColorAvailable()) { backgroundTypeValues.add(0, getString(commonR.string.widget_background_type_dynamiccolor)) } - binding.backgroundType.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, backgroundTypeValues) if (graphWidget != null) { binding.widgetTextConfigEntityId.setText(graphWidget.entityId) - binding.label.setText(graphWidget.label) - binding.textSize.setText(graphWidget.textSize.toInt().toString()) - binding.stateSeparator.setText(graphWidget.stateSeparator) + val entity = runBlocking { try { serverManager.integrationRepository(graphWidget.serverId).getEntity(graphWidget.entityId) @@ -160,7 +153,6 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { val attributeIds = graphWidget.attributeIds if (!attributeIds.isNullOrEmpty()) { - binding.appendAttributeValueCheckbox.isChecked = true appendAttributes = true for (item in attributeIds.split(',')) selectedAttributeIds.add(item) @@ -177,28 +169,9 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { binding.tapAction.isVisible = toggleable binding.tapActionList.setSelection(if (toggleable && graphWidget.tapAction == WidgetTapAction.TOGGLE) 0 else 1) - binding.backgroundType.setSelection( - when { - graphWidget.backgroundType == WidgetBackgroundType.DYNAMICCOLOR && DynamicColors.isDynamicColorAvailable() -> - backgroundTypeValues.indexOf(getString(commonR.string.widget_background_type_dynamiccolor)) - - graphWidget.backgroundType == WidgetBackgroundType.TRANSPARENT -> - backgroundTypeValues.indexOf(getString(commonR.string.widget_background_type_transparent)) - - else -> - backgroundTypeValues.indexOf(getString(commonR.string.widget_background_type_daynight)) - } - ) - binding.textColor.visibility = if (graphWidget.backgroundType == WidgetBackgroundType.TRANSPARENT) View.VISIBLE else View.GONE - binding.textColorWhite.isChecked = - graphWidget.textColor?.let { it.toColorInt() == ContextCompat.getColor(this, android.R.color.white) } ?: true - binding.textColorBlack.isChecked = - graphWidget.textColor?.let { it.toColorInt() == ContextCompat.getColor(this, commonR.color.colorWidgetButtonLabelBlack) } ?: false - binding.addButton.setText(commonR.string.update_widget) - } else { - binding.backgroundType.setSelection(0) } + entityAdapter = SingleItemArrayAdapter(this) { it?.entityId ?: "" } setupServerSelect(graphWidget?.serverId) @@ -212,28 +185,8 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { if (!binding.widgetTextConfigAttribute.isPopupShowing) binding.widgetTextConfigAttribute.showDropDown() } - binding.appendAttributeValueCheckbox.setOnCheckedChangeListener { _, isChecked -> - binding.attributeValueLinearLayout.isVisible = isChecked - appendAttributes = isChecked - } - binding.label.addTextChangedListener(labelTextChanged) - binding.backgroundType.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - binding.textColor.visibility = - if (parent?.adapter?.getItem(position) == getString(commonR.string.widget_background_type_transparent)) { - View.VISIBLE - } else { - View.GONE - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - binding.textColor.visibility = View.GONE - } - } - serverManager.defaultServers.forEach { server -> lifecycleScope.launch { try { @@ -359,16 +312,6 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { binding.label.text.toString() ) - intent.putExtra( - GraphWidget.EXTRA_TEXT_SIZE, - binding.textSize.text.toString() - ) - - intent.putExtra( - GraphWidget.EXTRA_STATE_SEPARATOR, - binding.stateSeparator.text.toString() - ) - if (appendAttributes) { val attributes = if (selectedAttributeIds.isEmpty()) { binding.widgetTextConfigAttribute.text.toString() @@ -394,27 +337,18 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { } ) - intent.putExtra( - GraphWidget.EXTRA_BACKGROUND_TYPE, - when (binding.backgroundType.selectedItem as String?) { - getString(commonR.string.widget_background_type_dynamiccolor) -> WidgetBackgroundType.DYNAMICCOLOR - getString(commonR.string.widget_background_type_transparent) -> WidgetBackgroundType.TRANSPARENT - else -> WidgetBackgroundType.DAYNIGHT - } - ) + val sliderValue = binding.hoursToSample.value - intent.putExtra( - GraphWidget.EXTRA_TEXT_COLOR, - if (binding.backgroundType.selectedItem as String? == getString(commonR.string.widget_background_type_transparent)) { - getHexForColor(if (binding.textColorWhite.isChecked) android.R.color.white else commonR.color.colorWidgetButtonLabelBlack) - } else { - null - } - ) + val hoursToSample = if (sliderValue == 0f) { + 24 + } else { + sliderValue.toInt() + } + + intent.putExtra(GraphWidget.EXTRA_HOURS_TO_SAMPLE, hoursToSample) context.sendBroadcast(intent) - // Make sure we pass back the original appWidgetId setResult( RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) diff --git a/app/src/main/res/layout/widget_graph_configure.xml b/app/src/main/res/layout/widget_graph_configure.xml index 96cad5b8a51..f2a4c771a6d 100644 --- a/app/src/main/res/layout/widget_graph_configure.xml +++ b/app/src/main/res/layout/widget_graph_configure.xml @@ -62,14 +62,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - + android:layout_height="wrap_content" + android:stepSize="1" + android:value="0" + android:valueFrom="0" + android:valueTo="48" /> - + android:text="@string/add_widget" + android:textColor="@color/colorBackground" /> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4a9e9cd02e..d6868513f55 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,4 +3,5 @@ Historical Graph We are retrieving some data… To see historical data on graph + Hours to sample: \ No newline at end of file From 10f880184c2dac701789731e569965eea36ff9b2 Mon Sep 17 00:00:00 2001 From: Ivor Smorenburg Date: Wed, 4 Sep 2024 23:13:39 +0200 Subject: [PATCH 13/57] Nicer UI Adding sampling and range --- app/build.gradle.kts | 2 + .../graph/GraphWidgetRepositoryImplTest.kt | 36 +- .../widgets/BaseWidgetConfigureActivity.kt | 4 - .../button/ButtonWidgetConfigureActivity.kt | 5 +- .../camera/CameraWidgetConfigureActivity.kt | 5 +- .../entity/EntityWidgetConfigureActivity.kt | 5 +- .../android/widgets/graph/GraphWidget.kt | 95 +- .../graph/GraphWidgetConfigureActivity.kt | 87 +- ...iaPlayerControlsWidgetConfigureActivity.kt | 8 +- .../TemplateWidgetConfigureActivity.kt | 6 +- .../res/layout/widget_graph_configure.xml | 323 +-- app/src/main/res/values/strings.xml | 5 +- .../graph/GraphWidgetRepositoryImplTest.kt | 40 +- .../48.json | 2575 +++++++++-------- .../android/common/data/BaseDaoRepository.kt | 5 +- .../data/widgets/GraphWidgetRepository.kt | 3 + .../data/widgets/GraphWidgetRepositoryImpl.kt | 13 +- .../database/widget/graph/GraphWidgetDao.kt | 25 +- .../widget/graph/GraphWidgetEntity.kt | 6 +- .../widget/graph/GraphWidgetHistoryEntity.kt | 4 +- gradle/libs.versions.toml | 2 + 21 files changed, 1606 insertions(+), 1648 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b293c99a5b..f805fc41ace 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -207,6 +207,8 @@ dependencies { androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.mockito.core) androidTestImplementation(libs.mockito.android) + + debugImplementation(libs.debug.db) } // Disable to fix memory leak and be compatible with the configuration cache. diff --git a/app/src/androidTest/kotlin/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt b/app/src/androidTest/kotlin/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt index 19aefa07650..ec8177b0ada 100644 --- a/app/src/androidTest/kotlin/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt +++ b/app/src/androidTest/kotlin/io/homeassistant/companion/android/widgets/graph/GraphWidgetRepositoryImplTest.kt @@ -1,10 +1,9 @@ package io.homeassistant.companion.android.widgets.graph import android.content.Context -import android.database.sqlite.SQLiteConstraintException import androidx.room.Room import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepositoryImpl import io.homeassistant.companion.android.database.AppDatabase import io.homeassistant.companion.android.database.widget.WidgetBackgroundType @@ -12,16 +11,15 @@ import io.homeassistant.companion.android.database.widget.WidgetTapAction import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao import io.homeassistant.companion.android.database.widget.graph.GraphWidgetEntity import io.homeassistant.companion.android.database.widget.graph.GraphWidgetHistoryEntity -import java.io.IOException import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import java.io.IOException -@RunWith(AndroidJUnit4::class) +@RunWith(AndroidJUnit4ClassRunner::class) class GraphWidgetRepositoryImplTest { private lateinit var graphWidgetDao: GraphWidgetDao @@ -35,7 +33,6 @@ class GraphWidgetRepositoryImplTest { entityId = "entityId", attributeIds = null, label = "Test Widget", - textSize = 14f, stateSeparator = ", ", attributeSeparator = ": ", tapAction = WidgetTapAction.REFRESH, @@ -63,15 +60,15 @@ class GraphWidgetRepositoryImplTest { @Test fun testInsertAndGetGraphWidget() = runBlocking { - graphWidgetRepository.add(Companion.WIDGET_OBJECT) + graphWidgetRepository.add(WIDGET_OBJECT) val retrievedWidget = graphWidgetRepository.get(1) - assertEquals(Companion.WIDGET_OBJECT, retrievedWidget) + assertEquals(WIDGET_OBJECT, retrievedWidget) } @Test fun testUpdateWidgetLastUpdate() = runBlocking { - graphWidgetRepository.add(Companion.WIDGET_OBJECT) + graphWidgetRepository.add(WIDGET_OBJECT) val newUpdate = "New Update" graphWidgetRepository.updateWidgetLastUpdate(1, newUpdate) @@ -82,11 +79,12 @@ class GraphWidgetRepositoryImplTest { @Test fun testInsertGraphWidgetHistory() = runBlocking { - graphWidgetRepository.add(Companion.WIDGET_OBJECT) + graphWidgetRepository.add(WIDGET_OBJECT) val historyEntity = GraphWidgetHistoryEntity( + id = 1, entityId = "history1", - graphWidgetId = 1, + graphWidgetId = WIDGET_OBJECT.id, state = "State1", sentState = System.currentTimeMillis() ) @@ -100,13 +98,13 @@ class GraphWidgetRepositoryImplTest { @Test fun testDeleteEntriesOlderThan() = runBlocking { - graphWidgetRepository.add(Companion.WIDGET_OBJECT) + graphWidgetRepository.add(WIDGET_OBJECT) val currentTime = System.currentTimeMillis() val oldTime = currentTime - (60 * 60 * 1000) // 1 hour ago - val historyEntity1 = GraphWidgetHistoryEntity("history1", 1, "State1", oldTime) - val historyEntity2 = GraphWidgetHistoryEntity("history2", 1, "State2", currentTime) + val historyEntity1 = GraphWidgetHistoryEntity(1,"history1", 1, "State1", oldTime) + val historyEntity2 = GraphWidgetHistoryEntity(2,"history2", 1, "State2", currentTime) graphWidgetRepository.insertGraphWidgetHistory(historyEntity1) graphWidgetRepository.insertGraphWidgetHistory(historyEntity2) @@ -118,14 +116,4 @@ class GraphWidgetRepositoryImplTest { assertEquals(historyEntity2, widgetHistories?.histories?.get(0)) } - @Test - fun testSaveHistoricWithoutAppWidgetShouldThrowExceptionForeignKey() = runBlocking { - val historyEntity1 = GraphWidgetHistoryEntity("history1", 1, "State1", System.currentTimeMillis()) - - assertThrows(SQLiteConstraintException::class.java) { - runBlocking { - graphWidgetDao.add(historyEntity1) - } - } - } } diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/BaseWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/BaseWidgetConfigureActivity.kt index e232ffd653f..5d31adeef75 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/BaseWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/BaseWidgetConfigureActivity.kt @@ -10,18 +10,14 @@ import android.widget.Toast import io.homeassistant.companion.android.BaseActivity import io.homeassistant.companion.android.common.R import io.homeassistant.companion.android.common.data.servers.ServerManager -import io.homeassistant.companion.android.database.widget.WidgetDao import javax.inject.Inject abstract class BaseWidgetConfigureActivity : BaseActivity() { - @Inject lateinit var serverManager: ServerManager protected var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID - abstract val dao: WidgetDao - abstract val serverSelect: View abstract val serverSelectList: Spinner diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt index f3a6bff9202..12cab0aed6e 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/button/ButtonWidgetConfigureActivity.kt @@ -34,7 +34,6 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.IIcon import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.Action import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.database.widget.ButtonWidgetDao @@ -49,8 +48,9 @@ import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.common.ActionFieldBinder import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter import io.homeassistant.companion.android.widgets.common.WidgetDynamicFieldAdapter -import javax.inject.Inject import kotlinx.coroutines.launch +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() { @@ -61,7 +61,6 @@ class ButtonWidgetConfigureActivity : BaseWidgetConfigureActivity() { @Inject lateinit var buttonWidgetDao: ButtonWidgetDao - override val dao get() = buttonWidgetDao private var actions = mutableMapOf>() private var entities = mutableMapOf>>() diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt index ecd7bd13497..027be53356f 100755 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/camera/CameraWidgetConfigureActivity.kt @@ -17,7 +17,6 @@ import android.widget.Toast import androidx.core.content.getSystemService import androidx.lifecycle.lifecycleScope import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.domain import io.homeassistant.companion.android.database.widget.CameraWidgetDao @@ -26,9 +25,10 @@ import io.homeassistant.companion.android.databinding.WidgetCameraConfigureBindi import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter -import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint class CameraWidgetConfigureActivity : BaseWidgetConfigureActivity() { @@ -53,7 +53,6 @@ class CameraWidgetConfigureActivity : BaseWidgetConfigureActivity() { @Inject lateinit var cameraWidgetDao: CameraWidgetDao - override val dao get() = cameraWidgetDao private var entityAdapter: SingleItemArrayAdapter>? = null diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt index f838af82eaf..5026b3b6c35 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/entity/EntityWidgetConfigureActivity.kt @@ -24,7 +24,6 @@ import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.google.android.material.color.DynamicColors import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.EntityExt import io.homeassistant.companion.android.common.data.integration.domain @@ -38,9 +37,10 @@ import io.homeassistant.companion.android.util.getHexForColor import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.BaseWidgetProvider import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter -import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint class EntityWidgetConfigureActivity : BaseWidgetConfigureActivity() { @@ -52,7 +52,6 @@ class EntityWidgetConfigureActivity : BaseWidgetConfigureActivity() { @Inject lateinit var staticWidgetDao: StaticWidgetDao - override val dao get() = staticWidgetDao private var entities = mutableMapOf>>() diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt index 44a16bb828e..6ad7c4ffe88 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidget.kt @@ -16,7 +16,6 @@ import android.widget.Toast import androidx.core.content.ContextCompat import androidx.core.graphics.toColorInt import androidx.core.os.BundleCompat -import com.fasterxml.jackson.annotation.ObjectIdGenerators.UUIDGenerator import com.github.mikephil.charting.charts.LineChart import com.github.mikephil.charting.components.Legend import com.github.mikephil.charting.components.XAxis @@ -27,7 +26,6 @@ import com.github.mikephil.charting.formatter.ValueFormatter import com.google.android.material.color.DynamicColors import dagger.hilt.android.AndroidEntryPoint import io.homeassistant.companion.android.R -import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.canSupportPrecision import io.homeassistant.companion.android.common.data.integration.friendlyState @@ -41,14 +39,16 @@ import io.homeassistant.companion.android.database.widget.graph.GraphWidgetWithH import io.homeassistant.companion.android.util.getAttribute import io.homeassistant.companion.android.widgets.BaseWidgetProvider import io.homeassistant.companion.android.widgets.entity.EntityWidget.Companion.EXTRA_STATE_SEPARATOR -import javax.inject.Inject import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint class GraphWidget : BaseWidgetProvider() { companion object { + private const val TAG = "GraphWidget" internal const val TOGGLE_ENTITY = "io.homeassistant.companion.android.widgets.entity.GraphWidget.TOGGLE_ENTITY" @@ -57,13 +57,14 @@ class GraphWidget : BaseWidgetProvider() { internal const val EXTRA_ENTITY_ID = "EXTRA_ENTITY_ID" internal const val EXTRA_ATTRIBUTE_IDS = "EXTRA_ATTRIBUTE_IDS" internal const val EXTRA_LABEL = "EXTRA_LABEL" - internal const val EXTRA_TEXT_SIZE = "EXTRA_TEXT_SIZE" - internal const val EXTRA_HOURS_TO_SAMPLE = "EXTRA_HOURS_TO_SAMPLE" internal const val EXTRA_ATTRIBUTE_SEPARATOR = "EXTRA_ATTRIBUTE_SEPARATOR" internal const val EXTRA_TAP_ACTION = "EXTRA_TAP_ACTION" internal const val EXTRA_BACKGROUND_TYPE = "EXTRA_BACKGROUND_TYPE" internal const val EXTRA_TEXT_COLOR = "EXTRA_TEXT_COLOR" + internal const val EXTRA_SAMPLING_MINUTES = "EXTRA_SAMPLING_MINUTES" + internal const val EXTRA_TIME_RANGE = "EXTRA_TIME_RANGE" + private data class ResolvedText(val text: CharSequence?, val exception: Boolean = false) } @@ -320,22 +321,9 @@ class GraphWidget : BaseWidgetProvider() { null } if (attributeIds == null) { - graphWidgetRepository.add( - GraphWidgetEntity( - appWidgetId, - serverId, - entityId.toString(), - null, - null, - 30F, - "", - "", - WidgetTapAction.REFRESH, - entity?.friendlyState(context, entityOptions) - ?: graphWidgetRepository.get(appWidgetId)?.lastUpdate ?: "", - WidgetBackgroundType.DAYNIGHT, - null - ) + graphWidgetRepository.updateWidgetLastUpdate( + appWidgetId, + entity?.friendlyState(context, entityOptions) ?: graphWidgetRepository.get(appWidgetId)?.lastUpdate ?: "" ) return ResolvedText(graphWidgetRepository.get(appWidgetId)?.lastUpdate, entityCaughtException) } @@ -347,22 +335,7 @@ class GraphWidget : BaseWidgetProvider() { val lastUpdate = entity?.friendlyState(context, entityOptions).plus(if (attributeValues.isNotEmpty()) stateSeparator else "") .plus(attributeValues.joinToString(attributeSeparator)) - graphWidgetRepository.add( - GraphWidgetEntity( - appWidgetId, - serverId, - entityId.toString(), - attributeIds, - null, - 30F, - stateSeparator, - attributeSeparator, - WidgetTapAction.REFRESH, - lastUpdate, - WidgetBackgroundType.DAYNIGHT, - null - ) - ) + graphWidgetRepository.updateWidgetLastUpdate(appWidgetId, lastUpdate) return ResolvedText(lastUpdate) } catch (e: Exception) { Log.e(TAG, "Unable to fetch entity state and attributes", e) @@ -377,7 +350,6 @@ class GraphWidget : BaseWidgetProvider() { val entitySelection: String? = extras.getString(EXTRA_ENTITY_ID) val attributeSelection: ArrayList? = extras.getStringArrayList(EXTRA_ATTRIBUTE_IDS) val labelSelection: String? = extras.getString(EXTRA_LABEL) - val textSizeSelection: String? = extras.getString(EXTRA_TEXT_SIZE) val stateSeparatorSelection: String? = extras.getString(EXTRA_STATE_SEPARATOR) val attributeSeparatorSelection: String? = extras.getString(EXTRA_ATTRIBUTE_SEPARATOR) val tapActionSelection = BundleCompat.getSerializable(extras, EXTRA_TAP_ACTION, WidgetTapAction::class.java) @@ -385,6 +357,8 @@ class GraphWidget : BaseWidgetProvider() { val backgroundTypeSelection = BundleCompat.getSerializable(extras, EXTRA_BACKGROUND_TYPE, WidgetBackgroundType::class.java) ?: WidgetBackgroundType.DAYNIGHT val textColorSelection: String? = extras.getString(EXTRA_TEXT_COLOR) + val samplingTime = extras.getInt(EXTRA_SAMPLING_MINUTES) + val timeRange = extras.getInt(EXTRA_TIME_RANGE) if (serverId == null || entitySelection == null) { Log.e(TAG, "Did not receive complete service call data") @@ -400,18 +374,19 @@ class GraphWidget : BaseWidgetProvider() { ) graphWidgetRepository.add( GraphWidgetEntity( - appWidgetId, - serverId, - entitySelection, - attributeSelection?.joinToString(","), - labelSelection, - textSizeSelection?.toFloatOrNull() ?: 30F, - stateSeparatorSelection ?: "", - attributeSeparatorSelection ?: "", - tapActionSelection, - graphWidgetRepository.get(appWidgetId)?.lastUpdate ?: "", - backgroundTypeSelection, - textColorSelection + id = appWidgetId, + serverId = serverId, + entityId = entitySelection, + attributeIds = attributeSelection?.joinToString(","), + label = labelSelection, + samplingTime = samplingTime, + timeRange = timeRange, + stateSeparator = stateSeparatorSelection ?: "", + attributeSeparator = attributeSeparatorSelection ?: "", + tapAction = tapActionSelection, + lastUpdate = graphWidgetRepository.get(appWidgetId)?.lastUpdate ?: "", + backgroundType = backgroundTypeSelection, + textColor = textColorSelection ) ) @@ -421,18 +396,26 @@ class GraphWidget : BaseWidgetProvider() { override suspend fun onEntityStateChanged(context: Context, appWidgetId: Int, entity: Entity<*>) { widgetScope?.launch { - // Clean up old entries before updating the widget - val oneHourInMillis = 60 * 60 * 1000L // 1 hour in milliseconds, this can be provided by UI + val graphEntity = entity as GraphWidgetEntity + val currentTimeMillis = System.currentTimeMillis() + + // this should delete older entries based on timerange for example 24 hours is not in millis int + val oneHourInMillis = currentTimeMillis - (60 * 60 * 1000 * entity.timeRange) + graphWidgetRepository.deleteEntriesOlderThan(appWidgetId, oneHourInMillis) + val samplingTimeInMillis = graphEntity.samplingTime * 60 * 1000 + graphWidgetRepository.insertGraphWidgetHistory( GraphWidgetHistoryEntity( - UUIDGenerator().generateId(entity.entityId + entity.lastChanged).toString(), - appWidgetId, - entity.friendlyState(context), - entity.lastChanged.timeInMillis + entityId = entity.entityId, + graphWidgetId = appWidgetId, + state = entity.friendlyState(context), + sentState = currentTimeMillis ) ) + + // Get views and update widget val views = getWidgetRemoteViews(context, appWidgetId, entity as Entity>) AppWidgetManager.getInstance(context).updateAppWidget(appWidgetId, views) } diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt index abd69b93ca1..acb7727c425 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/graph/GraphWidgetConfigureActivity.kt @@ -13,8 +13,6 @@ import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView -import android.widget.LinearLayout.VISIBLE -import android.widget.MultiAutoCompleteTextView.CommaTokenizer import android.widget.Spinner import android.widget.Toast import androidx.core.content.getSystemService @@ -22,38 +20,36 @@ import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.google.android.material.color.DynamicColors import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.EntityExt import io.homeassistant.companion.android.common.data.integration.domain import io.homeassistant.companion.android.common.data.integration.friendlyName +import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepository import io.homeassistant.companion.android.database.widget.WidgetTapAction -import io.homeassistant.companion.android.database.widget.graph.GraphWidgetDao import io.homeassistant.companion.android.databinding.WidgetGraphConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.BaseWidgetProvider import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter -import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { + @Inject + lateinit var repository: GraphWidgetRepository + companion object { private const val TAG: String = "GraphWidgetConfigAct" private const val PIN_WIDGET_CALLBACK = "io.homeassistant.companion.android.widgets.entity.GraphWidgetConfigureActivity.PIN_WIDGET_CALLBACK" } - @Inject - lateinit var graphWidgetDao: GraphWidgetDao - override val dao get() = graphWidgetDao - private var entities = mutableMapOf>>() private var selectedEntity: Entity? = null - private var appendAttributes: Boolean = false private var selectedAttributeIds: ArrayList = ArrayList() private var labelFromEntity = false @@ -123,8 +119,7 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { return } - // TODO to save a bit of time Im temporally using this dao to get the widget info - val graphWidget = graphWidgetDao.get(appWidgetId) + val graphWidget = repository.get(appWidgetId) val tapActionValues = listOf(getString(commonR.string.widget_tap_action_toggle), getString(commonR.string.refresh)) binding.tapActionList.adapter = ArrayAdapter(this, android.R.layout.simple_spinner_dropdown_item, tapActionValues) @@ -151,20 +146,6 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { } } - val attributeIds = graphWidget.attributeIds - if (!attributeIds.isNullOrEmpty()) { - appendAttributes = true - for (item in attributeIds.split(',')) - selectedAttributeIds.add(item) - binding.widgetTextConfigAttribute.setText(attributeIds.replace(",", ", ")) - binding.attributeValueLinearLayout.visibility = VISIBLE - binding.attributeSeparator.setText(graphWidget.attributeSeparator) - } - if (entity != null) { - selectedEntity = entity as Entity? - setupAttributes() - } - val toggleable = entity?.domain in EntityExt.APP_PRESS_ACTION_DOMAINS binding.tapAction.isVisible = toggleable binding.tapActionList.setSelection(if (toggleable && graphWidget.tapAction == WidgetTapAction.TOGGLE) 0 else 1) @@ -179,11 +160,6 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { binding.widgetTextConfigEntityId.setAdapter(entityAdapter) binding.widgetTextConfigEntityId.onFocusChangeListener = dropDownOnFocus binding.widgetTextConfigEntityId.onItemClickListener = entityDropDownOnItemClick - binding.widgetTextConfigAttribute.onFocusChangeListener = dropDownOnFocus - binding.widgetTextConfigAttribute.onItemClickListener = attributeDropDownOnItemClick - binding.widgetTextConfigAttribute.setOnClickListener { - if (!binding.widgetTextConfigAttribute.isPopupShowing) binding.widgetTextConfigAttribute.showDropDown() - } binding.label.addTextChangedListener(labelTextChanged) @@ -205,7 +181,6 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { override fun onServerSelected(serverId: Int) { selectedEntity = null binding.widgetTextConfigEntityId.setText("") - setupAttributes() setAdapterEntities(serverId) } @@ -237,7 +212,6 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { binding.label.addTextChangedListener(labelTextChanged) } } - setupAttributes() } private val attributeDropDownOnItemClick = @@ -259,20 +233,6 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { } } - private fun setupAttributes() { - val fetchedAttributes = selectedEntity?.attributes as? Map - val attributesAdapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line) - binding.widgetTextConfigAttribute.setAdapter(attributesAdapter) - attributesAdapter.addAll(*fetchedAttributes?.keys.orEmpty().toTypedArray()) - binding.widgetTextConfigAttribute.setTokenizer(CommaTokenizer()) - runOnUiThread { - val toggleable = selectedEntity?.domain in EntityExt.APP_PRESS_ACTION_DOMAINS - binding.tapAction.isVisible = toggleable - binding.tapActionList.setSelection(if (toggleable) 0 else 1) - attributesAdapter.notifyDataSetChanged() - } - } - private fun onAddWidget() { if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { showAddWidgetError() @@ -312,23 +272,6 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { binding.label.text.toString() ) - if (appendAttributes) { - val attributes = if (selectedAttributeIds.isEmpty()) { - binding.widgetTextConfigAttribute.text.toString() - } else { - selectedAttributeIds - } - intent.putExtra( - GraphWidget.EXTRA_ATTRIBUTE_IDS, - attributes - ) - - intent.putExtra( - GraphWidget.EXTRA_ATTRIBUTE_SEPARATOR, - binding.attributeSeparator.text.toString() - ) - } - intent.putExtra( GraphWidget.EXTRA_TAP_ACTION, when (binding.tapActionList.selectedItemPosition) { @@ -337,15 +280,23 @@ class GraphWidgetConfigureActivity : BaseWidgetConfigureActivity() { } ) - val sliderValue = binding.hoursToSample.value + val sliderTimeRange = binding.timeRange.value + val sliderSamplingValue = binding.hoursToSample.value - val hoursToSample = if (sliderValue == 0f) { + val graphTimeRange = if (sliderTimeRange == 0F) { 24 } else { - sliderValue.toInt() + sliderTimeRange.toInt() + } + + val graphSampleMinutes = if (sliderSamplingValue == 0F) { + 5 + } else { + sliderSamplingValue.toInt() } - intent.putExtra(GraphWidget.EXTRA_HOURS_TO_SAMPLE, hoursToSample) + intent.putExtra(GraphWidget.EXTRA_SAMPLING_MINUTES, graphSampleMinutes) + intent.putExtra(GraphWidget.EXTRA_TIME_RANGE, graphTimeRange) context.sendBroadcast(intent) diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/mediaplayer/MediaPlayerControlsWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/mediaplayer/MediaPlayerControlsWidgetConfigureActivity.kt index 0948f7e5390..d4d70484148 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/mediaplayer/MediaPlayerControlsWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/mediaplayer/MediaPlayerControlsWidgetConfigureActivity.kt @@ -17,19 +17,20 @@ import androidx.core.content.getSystemService import androidx.lifecycle.lifecycleScope import com.google.android.material.color.DynamicColors import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.common.R as commonR import io.homeassistant.companion.android.common.data.integration.Entity import io.homeassistant.companion.android.common.data.integration.domain +import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepository import io.homeassistant.companion.android.database.widget.MediaPlayerControlsWidgetDao import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.databinding.WidgetMediaControlsConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity import io.homeassistant.companion.android.widgets.common.SingleItemArrayAdapter -import java.util.LinkedList -import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import java.util.LinkedList +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint class MediaPlayerControlsWidgetConfigureActivity : BaseWidgetConfigureActivity() { @@ -43,7 +44,6 @@ class MediaPlayerControlsWidgetConfigureActivity : BaseWidgetConfigureActivity() @Inject lateinit var mediaPlayerControlsWidgetDao: MediaPlayerControlsWidgetDao - override val dao get() = mediaPlayerControlsWidgetDao private lateinit var binding: WidgetMediaControlsConfigureBinding diff --git a/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt b/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt index 5e284474e14..76a5545d0f8 100644 --- a/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt +++ b/app/src/main/java/io/homeassistant/companion/android/widgets/template/TemplateWidgetConfigureActivity.kt @@ -21,17 +21,18 @@ import androidx.lifecycle.lifecycleScope import com.fasterxml.jackson.databind.JsonMappingException import com.google.android.material.color.DynamicColors import dagger.hilt.android.AndroidEntryPoint -import io.homeassistant.companion.android.common.R as commonR +import io.homeassistant.companion.android.common.data.widgets.GraphWidgetRepository import io.homeassistant.companion.android.database.widget.TemplateWidgetDao import io.homeassistant.companion.android.database.widget.WidgetBackgroundType import io.homeassistant.companion.android.databinding.WidgetTemplateConfigureBinding import io.homeassistant.companion.android.settings.widgets.ManageWidgetsViewModel import io.homeassistant.companion.android.util.getHexForColor import io.homeassistant.companion.android.widgets.BaseWidgetConfigureActivity -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import javax.inject.Inject +import io.homeassistant.companion.android.common.R as commonR @AndroidEntryPoint class TemplateWidgetConfigureActivity : BaseWidgetConfigureActivity() { @@ -42,7 +43,6 @@ class TemplateWidgetConfigureActivity : BaseWidgetConfigureActivity() { @Inject lateinit var templateWidgetDao: TemplateWidgetDao - override val dao get() = templateWidgetDao private lateinit var binding: WidgetTemplateConfigureBinding diff --git a/app/src/main/res/layout/widget_graph_configure.xml b/app/src/main/res/layout/widget_graph_configure.xml index f2a4c771a6d..fedbd5bd989 100644 --- a/app/src/main/res/layout/widget_graph_configure.xml +++ b/app/src/main/res/layout/widget_graph_configure.xml @@ -1,192 +1,181 @@ - + android:layout_height="match_parent" + android:layout_gravity="center" + android:padding="16dp"> - + android:gravity="center" + android:text="@string/select_entity_to_display" + app:layout_constraintTop_toTopOf="parent" + tools:layout_editor_absoluteX="16dp" /> + + + android:layout_gravity="center_vertical" + android:padding="5dp" + android:text="@string/widget_spinner_server" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + - - - - - + android:layout_height="wrap_content" + android:completionThreshold="0" + android:imeOptions="actionNext|flagNoFullscreen" + android:inputType="text" /> + + + + + android:layout_gravity="center_vertical" + android:padding="5dp" + android:text="@string/label_label" /> - + android:hint="@string/widget_text_hint_label" + android:imeOptions="actionDone" + android:inputType="text" /> + + + -