Improve adding quick settings tile (#2860)

* Improve adding quick settings tiles

 - Use the new option available on Android 13 to prompt the user to add a tile to the quick settings panel, if a tile wasn't already added
 - Switch Toast to Snackbar
 - Move some logic from the manage tiles view to viewmodel

* Only show subtitle field when supported

 - Setting a tile subtitle is only supported on Android Q+, so don't show the field on versions before that!

* Deeplink into app for tiles that aren't setup

 - Instead of disabling a tile if it isn't setup, if the user is logged in set it up as inactive with the default icon/label and deeplink into the app settings with a notice that it needs to be setup first. This provides a better first use experience, as the user can simply tap it to setup instead of requiring them to manually go to the settings.
This commit is contained in:
Joris Pelgröm 2022-09-10 21:16:36 +02:00 committed by GitHub
parent 45cdaeb764
commit bdc9094845
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 1055 additions and 135 deletions

View file

@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi
class Tile10Service : TileExtensions() {
companion object {
private const val TILE_ID = "tile_10"
const val TILE_ID = "tile_10"
}
override fun getTile(): Tile? {

View file

@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi
class Tile11Service : TileExtensions() {
companion object {
private const val TILE_ID = "tile_11"
const val TILE_ID = "tile_11"
}
override fun getTile(): Tile? {

View file

@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi
class Tile12Service : TileExtensions() {
companion object {
private const val TILE_ID = "tile_12"
const val TILE_ID = "tile_12"
}
override fun getTile(): Tile? {

View file

@ -19,6 +19,6 @@ class Tile1Service : TileExtensions() {
}
companion object {
private const val TILE_ID = "tile_1"
const val TILE_ID = "tile_1"
}
}

View file

@ -19,6 +19,6 @@ class Tile2Service : TileExtensions() {
}
companion object {
private const val TILE_ID = "tile_2"
const val TILE_ID = "tile_2"
}
}

View file

@ -19,6 +19,6 @@ class Tile3Service : TileExtensions() {
}
companion object {
private const val TILE_ID = "tile_3"
const val TILE_ID = "tile_3"
}
}

View file

@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi
class Tile4Service : TileExtensions() {
companion object {
private const val TILE_ID = "tile_4"
const val TILE_ID = "tile_4"
}
override fun getTile(): Tile? {

View file

@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi
class Tile5Service : TileExtensions() {
companion object {
private const val TILE_ID = "tile_5"
const val TILE_ID = "tile_5"
}
override fun getTile(): Tile? {

View file

@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi
class Tile6Service : TileExtensions() {
companion object {
private const val TILE_ID = "tile_6"
const val TILE_ID = "tile_6"
}
override fun getTile(): Tile? {

View file

@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi
class Tile7Service : TileExtensions() {
companion object {
private const val TILE_ID = "tile_7"
const val TILE_ID = "tile_7"
}
override fun getTile(): Tile? {

View file

@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi
class Tile8Service : TileExtensions() {
companion object {
private const val TILE_ID = "tile_8"
const val TILE_ID = "tile_8"
}
override fun getTile(): Tile? {

View file

@ -8,7 +8,7 @@ import androidx.annotation.RequiresApi
class Tile9Service : TileExtensions() {
companion object {
private const val TILE_ID = "tile_9"
const val TILE_ID = "tile_9"
}
override fun getTile(): Tile? {

View file

@ -1,6 +1,7 @@
package io.homeassistant.companion.android.qs
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Icon
import android.os.Build
@ -14,9 +15,16 @@ import androidx.core.graphics.drawable.toBitmap
import com.maltaisn.icondialog.pack.IconPack
import com.maltaisn.icondialog.pack.IconPackLoader
import com.maltaisn.iconpack.mdi.createMaterialDesignIconPack
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.database.qs.TileDao
import io.homeassistant.companion.android.database.qs.TileEntity
import io.homeassistant.companion.android.database.qs.isSetup
import io.homeassistant.companion.android.settings.SettingsActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
@ -54,11 +62,24 @@ abstract class TileExtensions : TileService() {
override fun onTileAdded() {
super.onTileAdded()
Log.d(TAG, "Tile: ${getTileId()} added")
handleInject()
getTile()?.let { tile ->
mainScope.launch {
setTileData(getTileId(), tile)
}
}
MainScope().launch {
setTileAdded(getTileId(), true)
}
}
override fun onTileRemoved() {
super.onTileRemoved()
Log.d(TAG, "Tile: ${getTileId()} removed")
handleInject()
MainScope().launch {
setTileAdded(getTileId(), false)
}
}
override fun onStartListening() {
@ -81,7 +102,7 @@ abstract class TileExtensions : TileService() {
val context = applicationContext
val tileData = tileDao.get(tileId)
try {
return if (tileData != null) {
return if (tileData != null && tileData.isSetup) {
tile.label = tileData.label
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
tile.subtitle = tileData.subtitle
@ -106,8 +127,17 @@ abstract class TileExtensions : TileService() {
tile.updateTile()
true
} else {
Log.d(TAG, "No tile data found for tile ID: $tileId")
tile.state = Tile.STATE_UNAVAILABLE
if (tileData != null) {
Log.d(TAG, "Tile data found but not setup for tile ID: $tileId")
} else {
Log.d(TAG, "No tile data found for tile ID: $tileId")
}
tile.state =
if (integrationUseCase.isRegistered()) Tile.STATE_INACTIVE
else Tile.STATE_UNAVAILABLE
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
tile.subtitle = getString(commonR.string.tile_not_setup)
}
tile.updateTile()
false
}
@ -162,20 +192,38 @@ abstract class TileExtensions : TileService() {
tile.state = Tile.STATE_INACTIVE
tile.updateTile()
} else {
tile.state = Tile.STATE_UNAVAILABLE
tile.updateTile()
Log.d(TAG, "No tile data found for tile ID: $tileId")
withContext(Dispatchers.Main) {
Toast.makeText(
context,
commonR.string.tile_data_missing,
Toast.LENGTH_SHORT
startActivityAndCollapse(
SettingsActivity.newInstance(context).apply {
putExtra("fragment", "tiles/$tileId")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
}
)
.show()
}
}
}
private suspend fun setTileAdded(tileId: String, added: Boolean) {
tileDao.get(tileId)?.let {
tileDao.add(it.copy(added = added))
} ?: run {
if (added) { // Store an empty tile in the database to track added
tileDao.add(
TileEntity(
tileId = tileId,
added = added,
iconId = null,
entityId = "",
label = "",
subtitle = null
)
)
} // else if it doesn't exist and is removed we don't have to save anything
}
}
companion object {
private const val TAG = "TileExtensions"
private var iconPack: IconPack? = null
@ -200,4 +248,21 @@ abstract class TileExtensions : TileService() {
return null
}
}
private fun handleInject() {
// onTileAdded/onTileRemoved might be called outside onCreate - onDestroy, which usually
// handles injection. Because we need the DAO to save added/removed, inject it if required.
if (!this::tileDao.isInitialized) {
tileDao = EntryPointAccessors.fromApplication(
this@TileExtensions.applicationContext,
TileExtensionsEntryPoint::class.java
).tileDao()
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface TileExtensionsEntryPoint {
fun tileDao(): TileDao
}
}

View file

@ -14,6 +14,7 @@ import dagger.hilt.android.components.ActivityComponent
import io.homeassistant.companion.android.BaseActivity
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.settings.notification.NotificationHistoryFragment
import io.homeassistant.companion.android.settings.qs.ManageTilesFragment
import io.homeassistant.companion.android.settings.sensor.SensorDetailFragment
import io.homeassistant.companion.android.settings.websocket.WebsocketSettingFragment
import io.homeassistant.companion.android.common.R as commonR
@ -58,11 +59,15 @@ class SettingsActivity : BaseActivity() {
settingsNavigation == "websocket" -> WebsocketSettingFragment::class.java
settingsNavigation == "notification_history" -> NotificationHistoryFragment::class.java
settingsNavigation?.startsWith("sensors/") == true -> SensorDetailFragment::class.java
settingsNavigation?.startsWith("tiles/") == true -> ManageTilesFragment::class.java
else -> SettingsFragment::class.java
},
if (settingsNavigation?.startsWith("sensors/") == true) {
val sensorId = settingsNavigation.split("/")[1]
SensorDetailFragment.newInstance(sensorId).arguments
} else if (settingsNavigation?.startsWith("tiles/") == true) {
val tileId = settingsNavigation.split("/")[1]
Bundle().apply { putString("id", tileId) }
} else null
)
.commit()

View file

@ -5,7 +5,6 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentFactory
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.settings.language.LanguagesProvider
import io.homeassistant.companion.android.settings.qs.ManageTilesFragment
import javax.inject.Inject
class SettingsFragmentFactory @Inject constructor(
@ -17,7 +16,6 @@ class SettingsFragmentFactory @Inject constructor(
override fun instantiate(classLoader: ClassLoader, className: String): Fragment {
return when (className) {
SettingsFragment::class.java.name -> SettingsFragment(settingsPresenter, languagesProvider)
ManageTilesFragment::class.java.name -> ManageTilesFragment(integrationRepository)
else -> super.instantiate(classLoader, className)
}
}

View file

@ -17,14 +17,11 @@ import com.maltaisn.icondialog.IconDialogSettings
import com.maltaisn.icondialog.pack.IconPack
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.settings.qs.views.ManageTilesView
import io.homeassistant.companion.android.common.R as commonR
@AndroidEntryPoint
class ManageTilesFragment constructor(
val integrationRepository: IntegrationRepository
) : Fragment(), IconDialog.Callback {
class ManageTilesFragment : Fragment(), IconDialog.Callback {
companion object {
private const val TAG = "TileFragment"

View file

@ -1,39 +1,68 @@
package io.homeassistant.companion.android.settings.qs
import android.app.Application
import android.widget.Toast
import android.app.StatusBarManager
import android.content.ComponentName
import android.os.Build
import android.util.Log
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.getSystemService
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.maltaisn.icondialog.data.Icon
import com.maltaisn.icondialog.pack.IconPack
import com.maltaisn.icondialog.pack.IconPackLoader
import com.maltaisn.iconpack.mdi.createMaterialDesignIconPack
import dagger.hilt.android.lifecycle.HiltViewModel
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.IntegrationRepository
import io.homeassistant.companion.android.common.data.integration.domain
import io.homeassistant.companion.android.database.qs.TileDao
import io.homeassistant.companion.android.database.qs.TileEntity
import io.homeassistant.companion.android.database.qs.isSetup
import io.homeassistant.companion.android.qs.Tile10Service
import io.homeassistant.companion.android.qs.Tile11Service
import io.homeassistant.companion.android.qs.Tile12Service
import io.homeassistant.companion.android.qs.Tile1Service
import io.homeassistant.companion.android.qs.Tile2Service
import io.homeassistant.companion.android.qs.Tile3Service
import io.homeassistant.companion.android.qs.Tile4Service
import io.homeassistant.companion.android.qs.Tile5Service
import io.homeassistant.companion.android.qs.Tile6Service
import io.homeassistant.companion.android.qs.Tile7Service
import io.homeassistant.companion.android.qs.Tile8Service
import io.homeassistant.companion.android.qs.Tile9Service
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.concurrent.Executors
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@HiltViewModel
class ManageTilesViewModel @Inject constructor(
state: SavedStateHandle,
private val integrationUseCase: IntegrationRepository,
private val tileDao: TileDao,
application: Application
) : AndroidViewModel(application) {
companion object {
private const val TAG = "ManageTilesViewModel"
}
lateinit var iconPack: IconPack
private val app = application
val slots = loadTileSlots(application.resources)
var selectedTile by mutableStateOf(slots[0])
@ -41,19 +70,32 @@ class ManageTilesViewModel @Inject constructor(
var sortedEntities by mutableStateOf<List<Entity<*>>>(emptyList())
private set
var selectedIcon by mutableStateOf<Int?>(null)
private set
var selectedIconDrawable by mutableStateOf(AppCompatResources.getDrawable(application, R.drawable.ic_stat_ic_notification))
private set
var selectedTileId by mutableStateOf(0)
var selectedIconDrawable by mutableStateOf(AppCompatResources.getDrawable(application, commonR.drawable.ic_stat_ic_notification))
private set
var selectedEntityId by mutableStateOf("")
var tileLabel by mutableStateOf("")
var tileSubtitle by mutableStateOf<String?>(null)
var submitButtonLabel by mutableStateOf(commonR.string.tile_save)
private set
private var selectedIcon: Int? = null
private var selectedTileId = 0
private var selectedTileAdded = false
private val _tileInfoSnackbar = MutableSharedFlow<Int>(replay = 1)
var tileInfoSnackbar = _tileInfoSnackbar.asSharedFlow()
init {
// Initialize fields based on the tile_1 TileEntity
selectTile(0)
state.get<String>("id")?.let { id ->
selectTile(slots.indexOfFirst { it.id == id })
viewModelScope.launch {
// A deeplink only happens when tapping on a tile that hasn't been setup
_tileInfoSnackbar.emit(commonR.string.tile_data_missing)
}
} ?: run {
selectTile(0)
}
viewModelScope.launch(Dispatchers.IO) {
sortedEntities = integrationUseCase.getEntities().orEmpty()
@ -72,12 +114,18 @@ class ManageTilesViewModel @Inject constructor(
}
fun selectTile(index: Int) {
val tile = slots[index]
val tile = slots[if (index == -1) 0 else index]
selectedTile = tile
viewModelScope.launch {
tileDao.get(tile.id).also {
selectedTileId = it?.id ?: 0
it?.let { updateExistingTileFields(it) }
selectedTileAdded = it?.added ?: false
submitButtonLabel =
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2 || it?.added == true) commonR.string.tile_save
else commonR.string.tile_add
if (it?.isSetup == true) {
updateExistingTileFields(it)
}
}
}
}
@ -99,10 +147,63 @@ class ManageTilesViewModel @Inject constructor(
)
}
fun addTile(tileData: TileEntity) {
fun addTile() {
viewModelScope.launch {
val tileData = TileEntity(
id = selectedTileId,
tileId = selectedTile.id,
added = selectedTileAdded,
iconId = selectedIcon,
entityId = selectedEntityId,
label = tileLabel,
subtitle = tileSubtitle
)
tileDao.add(tileData)
Toast.makeText(getApplication(), R.string.tile_updated, Toast.LENGTH_SHORT).show()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !selectedTileAdded) {
val statusBarManager = app.getSystemService<StatusBarManager>()
val service = when (selectedTile.id) {
Tile2Service.TILE_ID -> Tile2Service::class.java
Tile3Service.TILE_ID -> Tile3Service::class.java
Tile4Service.TILE_ID -> Tile4Service::class.java
Tile5Service.TILE_ID -> Tile5Service::class.java
Tile6Service.TILE_ID -> Tile6Service::class.java
Tile7Service.TILE_ID -> Tile7Service::class.java
Tile8Service.TILE_ID -> Tile8Service::class.java
Tile9Service.TILE_ID -> Tile9Service::class.java
Tile10Service.TILE_ID -> Tile10Service::class.java
Tile11Service.TILE_ID -> Tile11Service::class.java
Tile12Service.TILE_ID -> Tile12Service::class.java
else -> Tile1Service::class.java
}
val icon = selectedIconDrawable?.let {
it.toBitmapOrNull(it.intrinsicWidth, it.intrinsicHeight)?.let { bitmap ->
android.graphics.drawable.Icon.createWithBitmap(bitmap)
}
} ?: android.graphics.drawable.Icon.createWithResource(app, commonR.drawable.ic_stat_ic_notification)
statusBarManager?.requestAddTileService(
ComponentName(app, service),
tileLabel,
icon,
Executors.newSingleThreadExecutor()
) { result ->
viewModelScope.launch {
Log.d(TAG, "Adding quick settings tile, system returned: $result")
if (result == StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ADDED ||
result == StatusBarManager.TILE_ADD_REQUEST_RESULT_TILE_ALREADY_ADDED
) {
_tileInfoSnackbar.emit(commonR.string.tile_added)
selectedTileAdded = true
submitButtonLabel = commonR.string.tile_save
} else { // Silently ignore error, database was still updated
_tileInfoSnackbar.emit(commonR.string.tile_updated)
}
}
}
} else {
_tileInfoSnackbar.emit(commonR.string.tile_updated)
}
}
}
}

View file

@ -1,5 +1,6 @@
package io.homeassistant.companion.android.settings.qs.views
import android.os.Build
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -13,9 +14,12 @@ import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.material.TextField
import androidx.compose.material.rememberScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -24,124 +28,138 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.graphics.drawable.toBitmap
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.database.qs.TileEntity
import io.homeassistant.companion.android.settings.qs.ManageTilesViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@Composable
fun ManageTilesView(
viewModel: ManageTilesViewModel,
onShowIconDialog: (tag: String?) -> Unit
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
var expandedTile by remember { mutableStateOf(false) }
var expandedEntity by remember { mutableStateOf(false) }
Box(modifier = Modifier.verticalScroll(scrollState)) {
Column(modifier = Modifier.padding(all = 16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.tile_select),
fontSize = 15.sp,
modifier = Modifier.padding(end = 10.dp)
)
Box {
OutlinedButton(onClick = { expandedTile = true }) {
Text(viewModel.selectedTile.name)
}
val scaffoldState = rememberScaffoldState()
LaunchedEffect("snackbar") {
viewModel.tileInfoSnackbar.onEach {
if (it != 0) {
scaffoldState.snackbarHostState.showSnackbar(context.getString(it))
}
}.launchIn(this)
}
DropdownMenu(expanded = expandedTile, onDismissRequest = { expandedTile = false }) {
for ((index, slot) in viewModel.slots.withIndex()) {
DropdownMenuItem(onClick = {
viewModel.selectTile(index)
expandedTile = false
}) {
Text(slot.name)
Scaffold(scaffoldState = scaffoldState) { contentPadding ->
Box(
modifier = Modifier
.padding(contentPadding)
.verticalScroll(scrollState)
) {
Column(modifier = Modifier.padding(all = 16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.tile_select),
fontSize = 15.sp,
modifier = Modifier.padding(end = 10.dp)
)
Box {
OutlinedButton(onClick = { expandedTile = true }) {
Text(viewModel.selectedTile.name)
}
DropdownMenu(expanded = expandedTile, onDismissRequest = { expandedTile = false }) {
for ((index, slot) in viewModel.slots.withIndex()) {
DropdownMenuItem(onClick = {
viewModel.selectTile(index)
expandedTile = false
}) {
Text(slot.name)
}
}
}
}
}
}
Divider()
TextField(
value = viewModel.tileLabel,
onValueChange = { viewModel.tileLabel = it },
label = {
Text(text = stringResource(id = R.string.tile_label))
},
modifier = Modifier.padding(10.dp).fillMaxWidth()
)
TextField(
value = viewModel.tileSubtitle.orEmpty(),
onValueChange = { viewModel.tileSubtitle = it },
label = {
Text(text = stringResource(id = R.string.tile_subtitle))
},
modifier = Modifier.padding(10.dp).fillMaxWidth()
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(id = R.string.tile_icon),
fontSize = 15.sp,
modifier = Modifier.padding(end = 10.dp)
Divider()
TextField(
value = viewModel.tileLabel,
onValueChange = { viewModel.tileLabel = it },
label = {
Text(text = stringResource(id = R.string.tile_label))
},
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
)
OutlinedButton(
onClick = { onShowIconDialog(viewModel.selectedTile.id) }
) {
val iconBitmap = remember(viewModel.selectedIconDrawable) {
viewModel.selectedIconDrawable?.toBitmap()?.asImageBitmap()
}
iconBitmap?.let {
Image(
iconBitmap,
contentDescription = stringResource(id = R.string.tile_icon),
colorFilter = ColorFilter.tint(colorResource(R.color.colorAccent))
)
}
}
}
Text(
text = stringResource(id = R.string.tile_entity),
fontSize = 15.sp
)
OutlinedButton(onClick = { expandedEntity = true }) {
Text(text = viewModel.selectedEntityId)
}
DropdownMenu(expanded = expandedEntity, onDismissRequest = { expandedEntity = false }) {
for (item in viewModel.sortedEntities) {
DropdownMenuItem(onClick = {
viewModel.selectedEntityId = item.entityId
expandedEntity = false
}) {
Text(text = item.entityId, fontSize = 15.sp)
}
}
}
Button(
onClick = {
val tileData = TileEntity(
id = viewModel.selectedTileId,
tileId = viewModel.selectedTile.id,
iconId = viewModel.selectedIcon,
entityId = viewModel.selectedEntityId,
label = viewModel.tileLabel,
subtitle = viewModel.tileSubtitle
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
TextField(
value = viewModel.tileSubtitle.orEmpty(),
onValueChange = { viewModel.tileSubtitle = it },
label = {
Text(text = stringResource(id = R.string.tile_subtitle))
},
modifier = Modifier
.padding(10.dp)
.fillMaxWidth()
)
viewModel.addTile(tileData)
},
enabled = viewModel.tileLabel.isNotEmpty() && viewModel.selectedEntityId.isNotEmpty()
) {
Text(stringResource(id = R.string.tile_save))
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(id = R.string.tile_icon),
fontSize = 15.sp,
modifier = Modifier.padding(end = 10.dp)
)
OutlinedButton(
onClick = { onShowIconDialog(viewModel.selectedTile.id) }
) {
val iconBitmap = remember(viewModel.selectedIconDrawable) {
viewModel.selectedIconDrawable?.toBitmap()?.asImageBitmap()
}
iconBitmap?.let {
Image(
iconBitmap,
contentDescription = stringResource(id = R.string.tile_icon),
colorFilter = ColorFilter.tint(colorResource(R.color.colorAccent))
)
}
}
}
Text(
text = stringResource(id = R.string.tile_entity),
fontSize = 15.sp
)
OutlinedButton(onClick = { expandedEntity = true }) {
Text(text = viewModel.selectedEntityId)
}
DropdownMenu(expanded = expandedEntity, onDismissRequest = { expandedEntity = false }) {
for (item in viewModel.sortedEntities) {
DropdownMenuItem(onClick = {
viewModel.selectedEntityId = item.entityId
expandedEntity = false
}) {
Text(text = item.entityId, fontSize = 15.sp)
}
}
}
Button(
onClick = { viewModel.addTile() },
enabled = viewModel.tileLabel.isNotBlank() && viewModel.selectedEntityId.isNotBlank()
) {
Text(stringResource(viewModel.submitButtonLabel))
}
}
}
}

View file

@ -0,0 +1,727 @@
{
"formatVersion": 1,
"database": {
"version": 33,
"identityHash": "5eab11aa0967d84bc4c14bdf053eb574",
"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": {
"columnNames": [
"sensor_id",
"name"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"host"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "sensors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `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`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"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": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"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": {
"columnNames": [
"sensor_id",
"name"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "button_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `icon_id` INTEGER 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": "iconId",
"columnName": "icon_id",
"affinity": "INTEGER",
"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": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "camera_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entityId` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "mediaplayctrls_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entityId` TEXT NOT NULL, `label` TEXT, `showSkip` INTEGER NOT NULL, `showSeek` INTEGER NOT NULL, `showVolume` INTEGER NOT NULL, `showSource` 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": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "showSkip",
"columnName": "showSkip",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showSeek",
"columnName": "showSeek",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showVolume",
"columnName": "showVolume",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "showSource",
"columnName": "showSource",
"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": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "static_widget",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `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, `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": "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": "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": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "template_widgets",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `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": "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": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"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)",
"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
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "qs_tiles",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tileId` TEXT NOT NULL, `added` INTEGER NOT NULL DEFAULT 1, `icon_id` INTEGER, `entityId` TEXT NOT NULL, `label` TEXT NOT NULL, `subtitle` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "tileId",
"columnName": "tileId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "added",
"columnName": "added",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "1"
},
{
"fieldPath": "iconId",
"columnName": "icon_id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "label",
"columnName": "label",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "subtitle",
"columnName": "subtitle",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"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": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "entityStateComplications",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `entityId` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "entityId",
"columnName": "entityId",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `websocketSetting` TEXT NOT NULL, `sensorUpdateFrequency` TEXT NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "websocketSetting",
"columnName": "websocketSetting",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sensorUpdateFrequency",
"columnName": "sensorUpdateFrequency",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": false
},
"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, '5eab11aa0967d84bc4c14bdf053eb574')"
]
}
}

View file

@ -74,7 +74,7 @@ import io.homeassistant.companion.android.common.R as commonR
EntityStateComplications::class,
Setting::class
],
version = 32,
version = 33,
autoMigrations = [
AutoMigration(from = 24, to = 25),
AutoMigration(from = 25, to = 26),
@ -84,6 +84,7 @@ import io.homeassistant.companion.android.common.R as commonR
AutoMigration(from = 29, to = 30),
AutoMigration(from = 30, to = 31),
AutoMigration(from = 31, to = 32),
AutoMigration(from = 32, to = 33),
]
)
@TypeConverters(

View file

@ -10,6 +10,8 @@ data class TileEntity(
val id: Int = 0,
@ColumnInfo(name = "tileId")
val tileId: String,
@ColumnInfo(name = "added", defaultValue = "1")
val added: Boolean,
@ColumnInfo(name = "icon_id")
val iconId: Int?,
@ColumnInfo(name = "entityId")
@ -19,3 +21,6 @@ data class TileEntity(
@ColumnInfo(name = "subtitle")
val subtitle: String?
)
val TileEntity.isSetup: Boolean
get() = this.label.isNotBlank() && this.entityId.isNotBlank()

View file

@ -735,16 +735,19 @@
<string name="tile_7">Tile 7</string>
<string name="tile_8">Tile 8</string>
<string name="tile_9">Tile 9</string>
<string name="tile_data_missing">Tile data is missing, please set it up in App Configuration</string>
<string name="tile_add">Add Tile</string>
<string name="tile_added">Tile added</string>
<string name="tile_data_missing">You need to set up the tile before using it</string>
<string name="tile_entity">Select a entity to toggle or call (required)</string>
<string name="tile_label">Tile Label (required)</string>
<string name="tile_list">List of Tiles</string>
<string name="tile_missing_entity_summary">You must have one of the following domains in order to use this feature: cover, fan, input_boolean, light, remote, scene, script, switch</string>
<string name="tile_missing_entity_title">Missing Valid Entity Domains</string>
<string name="tile_not_setup">Requires setup</string>
<string name="tile_save">Update Tile Data</string>
<string name="tile_settings">Tile settings</string>
<string name="tile_subtitle">Tile Subtitle</string>
<string name="tile_updated">Tile Data Updated</string>
<string name="tile_updated">Tile Data updated</string>
<string name="tiles">Tiles</string>
<string name="toast_message">%1$s was selected</string>
<string name="tts_error">Unable to process notification \"%1$s\" as text to speech.</string>