Add camera tile to Wear OS (#3870)

* [WIP] Prepare camera snapshot tile

* Deduplicate refresh views

* Get/save tile config in the database

* Deduplicate haptic click, add to camera refresh

* [WIP] Camera tile settings UI

 - Create UI to set camera tile settings
 - Reuse shortcut tile settings activity as a general tile settings activity

* Rename camera snapshot tile to camera tile

* Cleanup: camera entities selection, empty state, preview image

 - On initial load also create a list of camera entities to make it possible to select them without showing up elsewhere in the app
 - Add text to empty state instructing the user to set a camera
 - Update tile preview images

* Fix logged out state on missing tile config

* Force tile update on login

* Scale bitmap to fit inside screen size

 - Scale the received image to a bitmap that does not exceed the screen size to ensure timely refreshes and prevent parcels that are too big
This commit is contained in:
Joris Pelgröm 2023-09-17 22:57:33 +02:00 committed by GitHub
parent 8d4822a23d
commit 2c20baf0f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1879 additions and 160 deletions

View File

@ -87,7 +87,7 @@ fun SettingsWearTemplateTile(
expanded = dropdownExpanded,
onDismissRequest = { dropdownExpanded = false }
) {
val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60)
val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 2 * 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60)
for (option in options) {
DropdownMenuItem(onClick = {
onRefreshIntervalChanged(option)

File diff suppressed because it is too large Load Diff

View File

@ -50,6 +50,8 @@ import io.homeassistant.companion.android.database.settings.LocalNotificationSet
import io.homeassistant.companion.android.database.settings.LocalSensorSettingConverter
import io.homeassistant.companion.android.database.settings.Setting
import io.homeassistant.companion.android.database.settings.SettingsDao
import io.homeassistant.companion.android.database.wear.CameraTile
import io.homeassistant.companion.android.database.wear.CameraTileDao
import io.homeassistant.companion.android.database.wear.EntityStateComplications
import io.homeassistant.companion.android.database.wear.EntityStateComplicationsDao
import io.homeassistant.companion.android.database.wear.FavoriteCaches
@ -87,11 +89,12 @@ import io.homeassistant.companion.android.common.R as commonR
TileEntity::class,
Favorites::class,
FavoriteCaches::class,
CameraTile::class,
EntityStateComplications::class,
Server::class,
Setting::class
],
version = 43,
version = 44,
autoMigrations = [
AutoMigration(from = 24, to = 25),
AutoMigration(from = 25, to = 26),
@ -110,7 +113,8 @@ import io.homeassistant.companion.android.common.R as commonR
AutoMigration(from = 38, to = 39),
AutoMigration(from = 39, to = 40),
AutoMigration(from = 41, to = 42),
AutoMigration(from = 42, to = 43)
AutoMigration(from = 42, to = 43),
AutoMigration(from = 43, to = 44)
]
)
@TypeConverters(
@ -133,6 +137,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun tileDao(): TileDao
abstract fun favoritesDao(): FavoritesDao
abstract fun favoriteCachesDao(): FavoriteCachesDao
abstract fun cameraTileDao(): CameraTileDao
abstract fun entityStateComplicationsDao(): EntityStateComplicationsDao
abstract fun serverDao(): ServerDao
abstract fun settingsDao(): SettingsDao

View File

@ -12,6 +12,7 @@ import io.homeassistant.companion.android.database.qs.TileDao
import io.homeassistant.companion.android.database.sensor.SensorDao
import io.homeassistant.companion.android.database.server.ServerDao
import io.homeassistant.companion.android.database.settings.SettingsDao
import io.homeassistant.companion.android.database.wear.CameraTileDao
import io.homeassistant.companion.android.database.wear.EntityStateComplicationsDao
import io.homeassistant.companion.android.database.wear.FavoriteCachesDao
import io.homeassistant.companion.android.database.wear.FavoritesDao
@ -72,6 +73,9 @@ object DatabaseModule {
@Provides
fun provideSettingsDao(database: AppDatabase): SettingsDao = database.settingsDao()
@Provides
fun provideCameraTileDao(database: AppDatabase): CameraTileDao = database.cameraTileDao()
@Provides
fun provideEntityStateComplicationsDao(database: AppDatabase): EntityStateComplicationsDao = database.entityStateComplicationsDao()
}

View File

@ -0,0 +1,23 @@
package io.homeassistant.companion.android.database.wear
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
/**
* Represents the configuration of a camera tile.
* If the tile was added but not configured, everything except the tile ID will be `null`.
*/
@Entity(tableName = "camera_tiles")
data class CameraTile(
/** The system's tile ID */
@PrimaryKey
@ColumnInfo(name = "id")
val id: Int,
/** The camera entity ID */
@ColumnInfo(name = "entity_id")
val entityId: String? = null,
/** The refresh interval of this tile, in seconds */
@ColumnInfo(name = "refresh_interval")
val refreshInterval: Long? = null
)

View File

@ -0,0 +1,23 @@
package io.homeassistant.companion.android.database.wear
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface CameraTileDao {
@Query("SELECT * FROM camera_tiles WHERE id = :id")
suspend fun get(id: Int): CameraTile?
@Query("SELECT * FROM camera_tiles ORDER BY id ASC")
fun getAllFlow(): Flow<List<CameraTile>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun add(tile: CameraTile)
@Query("DELETE FROM camera_tiles where id = :id")
fun delete(id: Int)
}

View File

@ -120,6 +120,14 @@
<string name="button_forward">Next</string>
<string name="button_widget_desc">Call any service</string>
<string name="calendar">Calendar</string>
<string name="camera">Camera</string>
<string name="camera_tile">Camera tile</string>
<string name="camera_tile_desc">See what\'s on your camera</string>
<string name="camera_tile_log_in">Log in to Home Assistant to add a camera tile</string>
<string name="camera_tile_no_tiles_yet">There are no camera tiles added yet - add one from the watch face to set it up</string>
<string name="camera_tile_no_entity_yet">Edit the tile settings and select a camera to show</string>
<string name="camera_tile_n">Camera tile #%d</string>
<string name="camera_tiles">Camera tiles</string>
<string name="camera_widgets">Camera widgets</string>
<string name="camera_widget_desc">Displays the latest image from the camera</string>
<string name="cancel">Cancel</string>

View File

@ -84,7 +84,7 @@
</activity>
<activity
android:name=".tiles.OpenShortcutTileSettingsActivity"
android:name=".tiles.OpenTileSettingsActivity"
android:exported="true"
android:excludeFromRecents="true">
<intent-filter>
@ -92,6 +92,11 @@
<category android:name="android.intent.category.DEFAULT" />
<category android:name="com.google.android.clockwork.tiles.category.PROVIDER_CONFIG" />
</intent-filter>
<intent-filter>
<action android:name="ConfigCameraTile" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="com.google.android.clockwork.tiles.category.PROVIDER_CONFIG" />
</intent-filter>
</activity>
<!-- To show confirmations and failures -->
@ -167,6 +172,27 @@
<meta-data android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/conversation_tile_example" />
</service>
<service
android:name=".tiles.CameraTile"
android:label="@string/camera"
android:description="@string/camera_tile_desc"
android:permission="com.google.android.wearable.permission.BIND_TILE_PROVIDER"
android:exported="true">
<intent-filter>
<action android:name="androidx.wear.tiles.action.BIND_TILE_PROVIDER" />
</intent-filter>
<meta-data android:name="androidx.wear.tiles.PREVIEW"
android:resource="@drawable/camera_tile_example" />
<meta-data
android:name="com.google.android.clockwork.tiles.MULTI_INSTANCES_SUPPORTED"
android:value="true" /> <!-- This is supported starting from Wear OS 3 -->
<meta-data
android:name="com.google.android.clockwork.tiles.PROVIDER_CONFIG_ACTION"
android:value="ConfigCameraTile" />
</service>
<receiver android:name=".tiles.TileActionReceiver"
android:exported="false">
<intent-filter>

View File

@ -11,6 +11,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.home.views.DEEPLINK_PREFIX_SET_CAMERA_TILE
import io.homeassistant.companion.android.home.views.DEEPLINK_PREFIX_SET_SHORTCUT_TILE
import io.homeassistant.companion.android.home.views.LoadHomePage
import io.homeassistant.companion.android.onboarding.OnboardingActivity
@ -37,6 +38,16 @@ class HomeActivity : ComponentActivity(), HomeView {
return Intent(context, HomeActivity::class.java)
}
fun getCameraTileSettingsIntent(
context: Context,
tileId: Int
) = Intent(
Intent.ACTION_VIEW,
"$DEEPLINK_PREFIX_SET_CAMERA_TILE/$tileId".toUri(),
context,
HomeActivity::class.java
)
fun getShortcutsTileSettingsIntent(
context: Context,
tileId: Int

View File

@ -26,6 +26,8 @@ import io.homeassistant.companion.android.common.data.websocket.impl.entities.En
import io.homeassistant.companion.android.common.sensors.SensorManager
import io.homeassistant.companion.android.data.SimplifiedEntity
import io.homeassistant.companion.android.database.sensor.SensorDao
import io.homeassistant.companion.android.database.wear.CameraTile
import io.homeassistant.companion.android.database.wear.CameraTileDao
import io.homeassistant.companion.android.database.wear.FavoriteCaches
import io.homeassistant.companion.android.database.wear.FavoriteCachesDao
import io.homeassistant.companion.android.database.wear.FavoritesDao
@ -48,6 +50,7 @@ class MainViewModel @Inject constructor(
private val favoritesDao: FavoritesDao,
private val favoriteCachesDao: FavoriteCachesDao,
private val sensorsDao: SensorDao,
private val cameraTileDao: CameraTileDao,
application: Application
) : AndroidViewModel(application) {
@ -88,6 +91,10 @@ class MainViewModel @Inject constructor(
val shortcutEntitiesMap = mutableStateMapOf<Int?, SnapshotStateList<SimplifiedEntity>>()
val cameraTiles = cameraTileDao.getAllFlow().collectAsState()
var cameraEntitiesMap = mutableStateMapOf<String, SnapshotStateList<Entity<*>>>()
private set
var areas = mutableListOf<AreaRegistryResponse>()
private set
@ -221,6 +228,10 @@ class MainViewModel @Inject constructor(
getEntities.await()?.also {
entities.clear()
it.forEach { state -> updateEntityStates(state) }
// Special list: camera entities
val cameraEntities = it.filter { entity -> entity.domain == "camera" }
cameraEntitiesMap["camera"] = mutableStateListOf<Entity<*>>().apply { addAll(cameraEntities) }
}
if (!isFavoritesOnly) {
updateEntityDomains()
@ -412,6 +423,18 @@ class MainViewModel @Inject constructor(
}
}
fun setCameraTileEntity(tileId: Int, entityId: String) = viewModelScope.launch {
val current = cameraTileDao.get(tileId)
val updated = current?.copy(entityId = entityId) ?: CameraTile(id = tileId, entityId = entityId)
cameraTileDao.add(updated)
}
fun setCameraTileRefreshInterval(tileId: Int, interval: Long) = viewModelScope.launch {
val current = cameraTileDao.get(tileId)
val updated = current?.copy(refreshInterval = interval) ?: CameraTile(id = tileId, refreshInterval = interval)
cameraTileDao.add(updated)
}
fun setTileShortcut(tileId: Int?, index: Int, entity: SimplifiedEntity) {
viewModelScope.launch {
val shortcutEntities = shortcutEntitiesMap[tileId]!!

View File

@ -1,6 +1,9 @@
package io.homeassistant.companion.android.home.views
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavType
import androidx.navigation.navArgument
@ -12,11 +15,13 @@ import androidx.wear.tiles.TileService
import io.homeassistant.companion.android.common.sensors.id
import io.homeassistant.companion.android.home.MainViewModel
import io.homeassistant.companion.android.theme.WearAppTheme
import io.homeassistant.companion.android.tiles.CameraTile
import io.homeassistant.companion.android.tiles.ShortcutsTile
import io.homeassistant.companion.android.tiles.TemplateTile
import io.homeassistant.companion.android.views.ChooseEntityView
private const val ARG_SCREEN_SENSOR_MANAGER_ID = "sensorManagerId"
private const val ARG_SCREEN_CAMERA_TILE_ID = "cameraTileId"
private const val ARG_SCREEN_SHORTCUTS_TILE_ID = "shortcutsTileId"
private const val ARG_SCREEN_SHORTCUTS_TILE_ENTITY_INDEX = "shortcutsTileEntityIndex"
@ -27,6 +32,11 @@ private const val SCREEN_MANAGE_SENSORS = "manage_all_sensors"
private const val SCREEN_SINGLE_SENSOR_MANAGER = "sensor_manager"
private const val SCREEN_SETTINGS = "settings"
private const val SCREEN_SET_FAVORITES = "set_favorites"
private const val ROUTE_CAMERA_TILE = "camera_tile"
private const val SCREEN_SELECT_CAMERA_TILE = "select_camera_tile"
private const val SCREEN_SET_CAMERA_TILE = "set_camera_tile"
private const val SCREEN_SET_CAMERA_TILE_ENTITY = "entity"
private const val SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL = "refresh_interval"
private const val ROUTE_SHORTCUTS_TILE = "shortcuts_tile"
private const val SCREEN_SELECT_SHORTCUTS_TILE = "select_shortcuts_tile"
private const val SCREEN_SET_SHORTCUTS_TILE = "set_shortcuts_tile"
@ -35,6 +45,7 @@ private const val SCREEN_SET_TILE_TEMPLATE = "set_tile_template"
private const val SCREEN_SET_TILE_TEMPLATE_REFRESH_INTERVAL = "set_tile_template_refresh_interval"
const val DEEPLINK_SENSOR_MANAGER = "ha_wear://$SCREEN_SINGLE_SENSOR_MANAGER"
const val DEEPLINK_PREFIX_SET_CAMERA_TILE = "ha_wear://$SCREEN_SET_CAMERA_TILE"
const val DEEPLINK_PREFIX_SET_SHORTCUT_TILE = "ha_wear://$SCREEN_SET_SHORTCUTS_TILE"
@Composable
@ -147,6 +158,9 @@ fun LoadHomePage(
onHapticEnabled = { mainViewModel.setHapticEnabled(it) },
onToastEnabled = { mainViewModel.setToastEnabled(it) },
setFavoritesOnly = { mainViewModel.setWearFavoritesOnly(it) },
onClickCameraTile = {
swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$SCREEN_SELECT_CAMERA_TILE")
},
onClickTemplateTile = { swipeDismissableNavController.navigate(SCREEN_SET_TILE_TEMPLATE) },
onAssistantAppAllowed = mainViewModel::setAssistantApp
)
@ -163,6 +177,85 @@ fun LoadHomePage(
}
}
}
composable("$ROUTE_CAMERA_TILE/$SCREEN_SELECT_CAMERA_TILE") {
SelectCameraTileView(
tiles = mainViewModel.cameraTiles.value,
onSelectTile = { tileId ->
swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$tileId/$SCREEN_SET_CAMERA_TILE")
}
)
}
composable(
route = "$ROUTE_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}/$SCREEN_SET_CAMERA_TILE",
arguments = listOf(
navArgument(name = ARG_SCREEN_CAMERA_TILE_ID) {
type = NavType.IntType
}
),
deepLinks = listOf(
navDeepLink { uriPattern = "$DEEPLINK_PREFIX_SET_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}" }
)
) { backStackEntry ->
val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_CAMERA_TILE_ID)
SetCameraTileView(
tile = mainViewModel.cameraTiles.value.firstOrNull { it.id == tileId },
entities = mainViewModel.cameraEntitiesMap["camera"],
onSelectEntity = {
swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$tileId/$SCREEN_SET_CAMERA_TILE_ENTITY")
},
onSelectRefreshInterval = {
swipeDismissableNavController.navigate("$ROUTE_CAMERA_TILE/$tileId/$SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL")
}
)
}
composable(
route = "$ROUTE_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}/$SCREEN_SET_CAMERA_TILE_ENTITY",
arguments = listOf(
navArgument(name = ARG_SCREEN_CAMERA_TILE_ID) {
type = NavType.IntType
}
)
) { backStackEntry ->
val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_CAMERA_TILE_ID)
val cameraDomains = remember { mutableStateListOf("camera") }
val cameraFavorites = remember { mutableStateOf(emptyList<String>()) } // There are no camera favorites
ChooseEntityView(
entitiesByDomainOrder = cameraDomains,
entitiesByDomain = mainViewModel.cameraEntitiesMap,
favoriteEntityIds = cameraFavorites,
onNoneClicked = {},
onEntitySelected = { entity ->
tileId?.let {
mainViewModel.setCameraTileEntity(it, entity.entityId)
TileService.getUpdater(context).requestUpdate(CameraTile::class.java)
}
swipeDismissableNavController.navigateUp()
},
allowNone = false
)
}
composable(
route = "$ROUTE_CAMERA_TILE/{$ARG_SCREEN_CAMERA_TILE_ID}/$SCREEN_SET_CAMERA_TILE_REFRESH_INTERVAL",
arguments = listOf(
navArgument(name = ARG_SCREEN_CAMERA_TILE_ID) {
type = NavType.IntType
}
)
) { backStackEntry ->
val tileId = backStackEntry.arguments?.getInt(ARG_SCREEN_CAMERA_TILE_ID)
RefreshIntervalPickerView(
currentInterval = (
mainViewModel.cameraTiles.value
.firstOrNull { it.id == tileId }?.refreshInterval
?: CameraTile.DEFAULT_REFRESH_INTERVAL
).toInt()
) { interval ->
tileId?.let {
mainViewModel.setCameraTileRefreshInterval(it, interval.toLong())
}
swipeDismissableNavController.navigateUp()
}
}
composable("$ROUTE_SHORTCUTS_TILE/$SCREEN_SELECT_SHORTCUTS_TILE") {
SelectShortcutsTileView(
shortcutTileEntitiesCountById = mainViewModel.shortcutEntitiesMap.mapValues { (_, entities) -> entities.size },

View File

@ -39,7 +39,7 @@ fun RefreshIntervalPickerView(
currentInterval: Int,
onSelectInterval: (Int) -> Unit
) {
val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60)
val options = listOf(0, 60, 2 * 60, 5 * 60, 10 * 60, 15 * 60, 30 * 60, 60 * 60, 2 * 60 * 60, 5 * 60 * 60, 10 * 60 * 60, 24 * 60 * 60)
val initialIndex = options.indexOf(currentInterval)
val state = rememberPickerState(
initialNumberOfOptions = options.size,

View File

@ -0,0 +1,86 @@
package io.homeassistant.companion.android.home.views
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.wear.compose.foundation.lazy.itemsIndexed
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material.Chip
import androidx.wear.compose.material.ChipDefaults
import androidx.wear.compose.material.PositionIndicator
import androidx.wear.compose.material.Scaffold
import androidx.wear.compose.material.Text
import io.homeassistant.companion.android.database.wear.CameraTile
import io.homeassistant.companion.android.theme.WearAppTheme
import io.homeassistant.companion.android.views.ListHeader
import io.homeassistant.companion.android.views.ThemeLazyColumn
import io.homeassistant.companion.android.common.R as commonR
@Composable
fun SelectCameraTileView(
tiles: List<CameraTile>,
onSelectTile: (tileId: Int) -> Unit
) {
val scalingLazyListState = rememberScalingLazyListState()
WearAppTheme {
Scaffold(
positionIndicator = {
if (scalingLazyListState.isScrollInProgress) {
PositionIndicator(scalingLazyListState = scalingLazyListState)
}
},
timeText = { TimeText(scalingLazyListState = scalingLazyListState) }
) {
ThemeLazyColumn(state = scalingLazyListState) {
item {
ListHeader(id = commonR.string.camera_tiles)
}
if (tiles.isEmpty()) {
item {
Text(
text = stringResource(commonR.string.camera_tile_no_tiles_yet),
textAlign = TextAlign.Center
)
}
} else {
itemsIndexed(tiles, key = { _, item -> "tile.${item.id}" }) { index, tile ->
Chip(
modifier = Modifier.fillMaxWidth(),
label = {
Text(stringResource(commonR.string.camera_tile_n, index + 1))
},
secondaryLabel = if (tile.entityId != null) {
{ Text(tile.entityId!!) }
} else {
null
},
onClick = { onSelectTile(tile.id) },
colors = ChipDefaults.secondaryChipColors()
)
}
}
}
}
}
}
@Preview(device = Devices.WEAR_OS_LARGE_ROUND)
@Composable
private fun PreviewSelectCameraTileViewOne() {
SelectCameraTileView(
tiles = listOf(
CameraTile(id = 1, entityId = "camera.buienradar", refreshInterval = 300)
),
onSelectTile = {}
)
}
@Preview(device = Devices.WEAR_OS_LARGE_ROUND)
@Composable
private fun PreviewSelectCameraTileViewEmpty() {
SelectCameraTileView(tiles = emptyList(), onSelectTile = {})
}

View File

@ -0,0 +1,103 @@
package io.homeassistant.companion.android.home.views
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
import androidx.wear.compose.material.Chip
import androidx.wear.compose.material.ChipDefaults
import androidx.wear.compose.material.PositionIndicator
import androidx.wear.compose.material.Scaffold
import androidx.wear.compose.material.Text
import com.mikepenz.iconics.compose.Image
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import io.homeassistant.companion.android.common.R
import io.homeassistant.companion.android.common.data.integration.Entity
import io.homeassistant.companion.android.common.data.integration.friendlyName
import io.homeassistant.companion.android.common.data.integration.getIcon
import io.homeassistant.companion.android.database.wear.CameraTile
import io.homeassistant.companion.android.theme.WearAppTheme
import io.homeassistant.companion.android.theme.wearColorPalette
import io.homeassistant.companion.android.tiles.CameraTile.Companion.DEFAULT_REFRESH_INTERVAL
import io.homeassistant.companion.android.util.intervalToString
import io.homeassistant.companion.android.views.ListHeader
import io.homeassistant.companion.android.views.ThemeLazyColumn
import io.homeassistant.companion.android.common.R as commonR
@Composable
fun SetCameraTileView(
tile: CameraTile?,
entities: List<Entity<*>>?,
onSelectEntity: () -> Unit,
onSelectRefreshInterval: () -> Unit
) {
val scalingLazyListState = rememberScalingLazyListState()
WearAppTheme {
Scaffold(
positionIndicator = {
if (scalingLazyListState.isScrollInProgress) {
PositionIndicator(scalingLazyListState = scalingLazyListState)
}
},
timeText = { TimeText(scalingLazyListState = scalingLazyListState) }
) {
ThemeLazyColumn(state = scalingLazyListState) {
item {
ListHeader(commonR.string.camera_tile)
}
item {
val entity = tile?.entityId?.let { tileEntityId ->
entities?.firstOrNull { it.entityId == tileEntityId }
}
val icon = entity?.getIcon(LocalContext.current) ?: CommunityMaterial.Icon3.cmd_video
Chip(
modifier = Modifier.fillMaxWidth(),
icon = {
Image(
asset = icon,
colorFilter = ColorFilter.tint(wearColorPalette.onSurface)
)
},
colors = ChipDefaults.secondaryChipColors(),
label = {
Text(
text = stringResource(id = R.string.choose_entity)
)
},
secondaryLabel = {
Text(entity?.friendlyName ?: tile?.entityId ?: "")
},
onClick = onSelectEntity
)
}
item {
Chip(
modifier = Modifier.fillMaxWidth(),
icon = {
Image(
asset = CommunityMaterial.Icon3.cmd_timer_cog,
colorFilter = ColorFilter.tint(wearColorPalette.onSurface)
)
},
colors = ChipDefaults.secondaryChipColors(),
label = {
Text(
text = stringResource(id = R.string.refresh_interval)
)
},
secondaryLabel = {
Text(
intervalToString(LocalContext.current, (tile?.refreshInterval ?: DEFAULT_REFRESH_INTERVAL).toInt())
)
},
onClick = onSelectRefreshInterval
)
}
}
}
}
}

View File

@ -73,6 +73,7 @@ fun SettingsView(
onHapticEnabled: (Boolean) -> Unit,
onToastEnabled: (Boolean) -> Unit,
setFavoritesOnly: (Boolean) -> Unit,
onClickCameraTile: () -> Unit,
onClickTemplateTile: () -> Unit,
onAssistantAppAllowed: (Boolean) -> Unit
) {
@ -212,6 +213,13 @@ fun SettingsView(
id = commonR.string.tile_settings
)
}
item {
SecondarySettingsChip(
icon = CommunityMaterial.Icon3.cmd_video_box,
label = stringResource(commonR.string.camera_tiles),
onClick = onClickCameraTile
)
}
item {
SecondarySettingsChip(
icon = CommunityMaterial.Icon3.cmd_star_circle_outline,
@ -323,6 +331,7 @@ private fun PreviewSettingsView() {
onHapticEnabled = {},
onToastEnabled = {},
setFavoritesOnly = {},
onClickCameraTile = {},
onClickTemplateTile = {},
onAssistantAppAllowed = {}
)

View File

@ -8,6 +8,7 @@ import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.onboarding.getMessagingToken
import io.homeassistant.companion.android.tiles.CameraTile
import io.homeassistant.companion.android.tiles.ConversationTile
import io.homeassistant.companion.android.tiles.ShortcutsTile
import io.homeassistant.companion.android.tiles.TemplateTile
@ -60,6 +61,7 @@ class MobileAppIntegrationPresenterImpl @Inject constructor(
try {
val context = view as Context
val updater = TileService.getUpdater(context)
updater.requestUpdate(CameraTile::class.java)
updater.requestUpdate(ConversationTile::class.java)
updater.requestUpdate(ShortcutsTile::class.java)
updater.requestUpdate(TemplateTile::class.java)

View File

@ -32,6 +32,7 @@ import io.homeassistant.companion.android.database.wear.replaceAll
import io.homeassistant.companion.android.home.HomeActivity
import io.homeassistant.companion.android.home.HomePresenterImpl
import io.homeassistant.companion.android.onboarding.getMessagingToken
import io.homeassistant.companion.android.tiles.CameraTile
import io.homeassistant.companion.android.tiles.ConversationTile
import io.homeassistant.companion.android.tiles.ShortcutsTile
import io.homeassistant.companion.android.tiles.TemplateTile
@ -223,6 +224,7 @@ class PhoneSettingsListener : WearableListenerService(), DataClient.OnDataChange
private fun updateTiles() = mainScope.launch {
try {
val updater = TileService.getUpdater(applicationContext)
updater.requestUpdate(CameraTile::class.java)
updater.requestUpdate(ConversationTile::class.java)
updater.requestUpdate(ShortcutsTile::class.java)
updater.requestUpdate(TemplateTile::class.java)

View File

@ -0,0 +1,235 @@
package io.homeassistant.companion.android.tiles
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.wear.protolayout.DimensionBuilders
import androidx.wear.protolayout.LayoutElementBuilders
import androidx.wear.protolayout.LayoutElementBuilders.CONTENT_SCALE_MODE_FIT
import androidx.wear.protolayout.ResourceBuilders
import androidx.wear.protolayout.ResourceBuilders.ImageResource
import androidx.wear.protolayout.ResourceBuilders.InlineImageResource
import androidx.wear.protolayout.ResourceBuilders.Resources
import androidx.wear.protolayout.TimelineBuilders.Timeline
import androidx.wear.tiles.EventBuilders
import androidx.wear.tiles.RequestBuilders
import androidx.wear.tiles.TileBuilders.Tile
import androidx.wear.tiles.TileService
import com.google.common.util.concurrent.ListenableFuture
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository
import io.homeassistant.companion.android.common.data.servers.ServerManager
import io.homeassistant.companion.android.database.AppDatabase
import io.homeassistant.companion.android.database.wear.CameraTile
import io.homeassistant.companion.android.util.UrlUtil
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.guava.future
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
@AndroidEntryPoint
class CameraTile : TileService() {
companion object {
private const val TAG = "CameraTile"
const val DEFAULT_REFRESH_INTERVAL = 3600L // 1 hour, matching phone widget
private const val RESOURCE_SNAPSHOT = "snapshot"
}
private val serviceJob = Job()
private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
@Inject
lateinit var serverManager: ServerManager
@Inject
lateinit var wearPrefsRepository: WearPrefsRepository
@Inject
lateinit var okHttpClient: OkHttpClient
override fun onTileRequest(requestParams: RequestBuilders.TileRequest): ListenableFuture<Tile> =
serviceScope.future {
val tileId = requestParams.tileId
val tileConfig = AppDatabase.getInstance(this@CameraTile)
.cameraTileDao()
.get(tileId)
if (requestParams.currentState.lastClickableId == MODIFIER_CLICK_REFRESH) {
if (wearPrefsRepository.getWearHapticFeedback()) hapticClick(applicationContext)
}
Tile.Builder()
.setResourcesVersion("$TAG$tileId.${System.currentTimeMillis()}")
.setFreshnessIntervalMillis(
TimeUnit.SECONDS.toMillis(tileConfig?.refreshInterval ?: DEFAULT_REFRESH_INTERVAL)
)
.setTileTimeline(
if (serverManager.isRegistered()) {
timeline(
requestParams.deviceConfiguration.screenWidthDp,
requestParams.deviceConfiguration.screenHeightDp,
tileConfig?.entityId.isNullOrBlank()
)
} else {
loggedOutTimeline(
this@CameraTile,
requestParams,
commonR.string.camera,
commonR.string.camera_tile_log_in
)
}
)
.build()
}
override fun onTileResourcesRequest(requestParams: RequestBuilders.ResourcesRequest): ListenableFuture<Resources> =
serviceScope.future {
var imageWidth = 0
var imageHeight = 0
val imageData = if (serverManager.isRegistered()) {
val tileId = requestParams.tileId
val tileConfig = AppDatabase.getInstance(this@CameraTile)
.cameraTileDao()
.get(tileId)
try {
val entity = tileConfig?.entityId?.let {
serverManager.integrationRepository().getEntity(it)
}
val picture = entity?.attributes?.get("entity_picture")?.toString()
val url = UrlUtil.handle(serverManager.getServer()?.connection?.getUrl(), picture ?: "")
if (picture != null && url != null) {
var byteArray: ByteArray?
val maxWidth = requestParams.deviceConfiguration.screenWidthDp * requestParams.deviceConfiguration.screenDensity
val maxHeight = requestParams.deviceConfiguration.screenHeightDp * requestParams.deviceConfiguration.screenDensity
withContext(Dispatchers.IO) {
val response = okHttpClient.newCall(Request.Builder().url(url).build()).execute()
byteArray = response.body?.byteStream()?.readBytes()
byteArray?.let {
var bitmap = BitmapFactory.decodeByteArray(it, 0, it.size)
if (bitmap.width > maxWidth || bitmap.height > maxHeight) {
Log.d(TAG, "Scaling camera snapshot to fit screen (${bitmap.width}x${bitmap.height} to ${maxWidth.toInt()}x${maxHeight.toInt()} max)")
val currentRatio = (bitmap.width.toFloat() / bitmap.height.toFloat())
val screenRatio = (requestParams.deviceConfiguration.screenWidthDp.toFloat() / requestParams.deviceConfiguration.screenHeightDp.toFloat())
imageWidth = maxWidth.toInt()
imageHeight = maxHeight.toInt()
if (currentRatio > screenRatio) {
imageWidth = (maxHeight * currentRatio).toInt()
} else {
imageHeight = (maxWidth / currentRatio).toInt()
}
bitmap = Bitmap.createScaledBitmap(bitmap, imageWidth, imageHeight, true)
ByteArrayOutputStream().use { stream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, stream)
byteArray = stream.toByteArray()
}
} else {
imageWidth = bitmap.width
imageHeight = bitmap.height
}
}
response.close()
}
byteArray
} else {
null
}
} catch (e: Exception) {
Log.e(TAG, "Unable to fetch entity ${tileConfig?.entityId}", e)
null
}
} else {
null
}
val builder = Resources.Builder()
.setVersion(requestParams.version)
.addIdToImageMapping(
RESOURCE_REFRESH,
ImageResource.Builder()
.setAndroidResourceByResId(
ResourceBuilders.AndroidImageResourceByResId.Builder()
.setResourceId(R.drawable.ic_refresh)
.build()
).build()
)
if (imageData != null) {
builder.addIdToImageMapping(
RESOURCE_SNAPSHOT,
ImageResource.Builder()
.setInlineResource(
InlineImageResource.Builder()
.setData(imageData)
.setWidthPx(imageWidth)
.setHeightPx(imageHeight)
.setFormat(ResourceBuilders.IMAGE_FORMAT_UNDEFINED)
.build()
)
.build()
)
}
builder.build()
}
override fun onTileAddEvent(requestParams: EventBuilders.TileAddEvent) {
serviceScope.launch {
AppDatabase.getInstance(this@CameraTile)
.cameraTileDao()
.add(CameraTile(id = requestParams.tileId))
}
}
override fun onTileRemoveEvent(requestParams: EventBuilders.TileRemoveEvent) {
serviceScope.launch {
AppDatabase.getInstance(this@CameraTile)
.cameraTileDao()
.delete(requestParams.tileId)
}
}
override fun onDestroy() {
super.onDestroy()
serviceScope.cancel()
}
private fun timeline(width: Int, height: Int, requiresSetup: Boolean): Timeline = Timeline.fromLayoutElement(
LayoutElementBuilders.Box.Builder().apply {
// Camera image
if (requiresSetup) {
addContent(
LayoutElementBuilders.Text.Builder()
.setText(getString(commonR.string.camera_tile_no_entity_yet))
.setMaxLines(10)
.build()
)
} else {
addContent(
LayoutElementBuilders.Image.Builder()
.setResourceId(RESOURCE_SNAPSHOT)
.setWidth(DimensionBuilders.dp(width.toFloat()))
.setHeight(DimensionBuilders.dp(height.toFloat()))
.setContentScaleMode(CONTENT_SCALE_MODE_FIT)
.build()
)
}
// Refresh button
addContent(getRefreshButton())
// Click: refresh
setModifiers(getRefreshModifiers())
}.build()
)
}

View File

@ -1,76 +0,0 @@
package io.homeassistant.companion.android.tiles
import android.content.Context
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.wear.protolayout.ActionBuilders
import androidx.wear.protolayout.ColorBuilders.argb
import androidx.wear.protolayout.ModifiersBuilders
import androidx.wear.protolayout.TimelineBuilders.Timeline
import androidx.wear.protolayout.material.ChipColors
import androidx.wear.protolayout.material.Colors
import androidx.wear.protolayout.material.CompactChip
import androidx.wear.protolayout.material.Text
import androidx.wear.protolayout.material.Typography
import androidx.wear.protolayout.material.layouts.PrimaryLayout
import androidx.wear.tiles.RequestBuilders
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.splash.SplashActivity
import io.homeassistant.companion.android.common.R as commonR
/**
* A [Timeline] with a single entry, asking the user to log in to the app to start using the tile
* with a button to open the app. The tile is using the 'Dialog' style.
*/
fun loggedOutTimeline(
context: Context,
requestParams: RequestBuilders.TileRequest,
@StringRes title: Int,
@StringRes text: Int
): Timeline {
val theme = Colors(
ContextCompat.getColor(context, R.color.colorPrimary), // Primary
ContextCompat.getColor(context, R.color.colorOnPrimary), // On primary
ContextCompat.getColor(context, R.color.colorOverlay), // Surface
ContextCompat.getColor(context, android.R.color.white) // On surface
)
val chipColors = ChipColors.primaryChipColors(theme)
val chipAction = ModifiersBuilders.Clickable.Builder()
.setId("login")
.setOnClick(
ActionBuilders.LaunchAction.Builder()
.setAndroidActivity(
ActionBuilders.AndroidActivity.Builder()
.setClassName(SplashActivity::class.java.name)
.setPackageName(context.packageName)
.build()
).build()
).build()
return Timeline.fromLayoutElement(
PrimaryLayout.Builder(requestParams.deviceConfiguration)
.setPrimaryLabelTextContent(
Text.Builder(context, context.getString(title))
.setTypography(Typography.TYPOGRAPHY_CAPTION1)
.setColor(argb(theme.primary))
.build()
)
.setContent(
Text.Builder(context, context.getString(text))
.setTypography(Typography.TYPOGRAPHY_BODY1)
.setMaxLines(10)
.setColor(argb(theme.onSurface))
.build()
)
.setPrimaryChipContent(
CompactChip.Builder(
context,
context.getString(commonR.string.login),
chipAction,
requestParams.deviceConfiguration
)
.setChipColors(chipColors)
.build()
)
.build()
)
}

View File

@ -10,7 +10,7 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class OpenShortcutTileSettingsActivity : AppCompatActivity() {
class OpenTileSettingsActivity : AppCompatActivity() {
@Inject
lateinit var wearPrefsRepository: WearPrefsRepositoryImpl
@ -19,14 +19,25 @@ class OpenShortcutTileSettingsActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
val tileId = intent.extras?.getInt("com.google.android.clockwork.EXTRA_PROVIDER_CONFIG_TILE_ID")
tileId?.takeIf { it != 0 }?.let {
lifecycleScope.launch {
wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId)
val settingsIntent = when (intent.action) {
"ConfigCameraTile" ->
HomeActivity.getCameraTileSettingsIntent(
context = this,
tileId = it
)
"ConfigShortcutsTile" -> {
lifecycleScope.launch {
wearPrefsRepository.getTileShortcutsAndSaveTileId(tileId)
}
HomeActivity.getShortcutsTileSettingsIntent(
context = this,
tileId = it
)
}
else -> null
}
val intent = HomeActivity.getShortcutsTileSettingsIntent(
context = this,
tileId = it
)
startActivity(intent)
settingsIntent?.let { startActivity(settingsIntent) }
}
finish()
}

View File

@ -1,10 +1,6 @@
package io.homeassistant.companion.android.tiles
import android.graphics.Typeface
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.text.style.AbsoluteSizeSpan
import android.text.style.CharacterStyle
import android.text.style.ForegroundColorSpan
@ -12,18 +8,14 @@ import android.text.style.RelativeSizeSpan
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import android.util.Log
import androidx.core.content.getSystemService
import androidx.core.text.HtmlCompat.FROM_HTML_MODE_LEGACY
import androidx.core.text.HtmlCompat.fromHtml
import androidx.wear.protolayout.ActionBuilders
import androidx.wear.protolayout.ColorBuilders
import androidx.wear.protolayout.DimensionBuilders
import androidx.wear.protolayout.DimensionBuilders.dp
import androidx.wear.protolayout.LayoutElementBuilders
import androidx.wear.protolayout.LayoutElementBuilders.Box
import androidx.wear.protolayout.LayoutElementBuilders.FONT_WEIGHT_BOLD
import androidx.wear.protolayout.LayoutElementBuilders.LayoutElement
import androidx.wear.protolayout.ModifiersBuilders
import androidx.wear.protolayout.ResourceBuilders
import androidx.wear.protolayout.ResourceBuilders.Resources
import androidx.wear.protolayout.TimelineBuilders.Timeline
@ -57,19 +49,8 @@ class TemplateTile : TileService() {
override fun onTileRequest(requestParams: TileRequest): ListenableFuture<Tile> =
serviceScope.future {
val state = requestParams.currentState
if (state.lastClickableId == "refresh") {
if (wearPrefsRepository.getWearHapticFeedback()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = applicationContext.getSystemService<VibratorManager>()
val vibrator = vibratorManager?.defaultVibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
val vibrator = applicationContext.getSystemService<Vibrator>()
@Suppress("DEPRECATION")
vibrator?.vibrate(200)
}
}
if (requestParams.currentState.lastClickableId == MODIFIER_CLICK_REFRESH) {
if (wearPrefsRepository.getWearHapticFeedback()) hapticClick(applicationContext)
}
Tile.Builder()
@ -96,7 +77,7 @@ class TemplateTile : TileService() {
Resources.Builder()
.setVersion("1")
.addIdToImageMapping(
"refresh",
RESOURCE_REFRESH,
ResourceBuilders.ImageResource.Builder()
.setAndroidResourceByResId(
ResourceBuilders.AndroidImageResourceByResId.Builder()
@ -147,43 +128,11 @@ class TemplateTile : TileService() {
parseHtml(renderedText)
)
}
addContent(
LayoutElementBuilders.Arc.Builder()
.setAnchorAngle(
DimensionBuilders.DegreesProp.Builder(180f).build()
)
.addContent(
LayoutElementBuilders.ArcAdapter.Builder()
.setContent(
LayoutElementBuilders.Image.Builder()
.setResourceId("refresh")
.setWidth(dp(24f))
.setHeight(dp(24f))
.setModifiers(getRefreshModifiers())
.build()
)
.setRotateContents(false)
.build()
)
.build()
)
addContent(getRefreshButton())
setModifiers(getRefreshModifiers())
}
.build()
private fun getRefreshModifiers(): ModifiersBuilders.Modifiers {
return ModifiersBuilders.Modifiers.Builder()
.setClickable(
ModifiersBuilders.Clickable.Builder()
.setOnClick(
ActionBuilders.LoadAction.Builder().build()
)
.setId("refresh")
.build()
)
.build()
}
private fun parseHtml(renderedText: String): LayoutElementBuilders.Spannable {
// Replace control char \r\n, \r, \n and also \r\n, \r, \n as text literals in strings to <br>
val renderedSpanned = fromHtml(renderedText.replace("(\r\n|\r|\n)|(\\\\r\\\\n|\\\\r|\\\\n)".toRegex(), "<br>"), FROM_HTML_MODE_LEGACY)

View File

@ -3,12 +3,7 @@ package io.homeassistant.companion.android.tiles
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import androidx.core.content.getSystemService
import dagger.hilt.android.AndroidEntryPoint
import io.homeassistant.companion.android.common.data.integration.onEntityPressedWithoutState
import io.homeassistant.companion.android.common.data.prefs.WearPrefsRepository
@ -34,17 +29,7 @@ class TileActionReceiver : BroadcastReceiver() {
if (entityId != null) {
runBlocking {
if (wearPrefsRepository.getWearHapticFeedback()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context?.getSystemService<VibratorManager>()
val vibrator = vibratorManager?.defaultVibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
val vibrator = context?.getSystemService<Vibrator>()
@Suppress("DEPRECATION")
vibrator?.vibrate(200)
}
}
if (wearPrefsRepository.getWearHapticFeedback() && context != null) hapticClick(context)
try {
onEntityPressedWithoutState(

View File

@ -0,0 +1,157 @@
package io.homeassistant.companion.android.tiles
import android.content.Context
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.wear.protolayout.ActionBuilders
import androidx.wear.protolayout.ColorBuilders.argb
import androidx.wear.protolayout.DimensionBuilders
import androidx.wear.protolayout.LayoutElementBuilders
import androidx.wear.protolayout.ModifiersBuilders
import androidx.wear.protolayout.TimelineBuilders.Timeline
import androidx.wear.protolayout.material.ChipColors
import androidx.wear.protolayout.material.Colors
import androidx.wear.protolayout.material.CompactChip
import androidx.wear.protolayout.material.Text
import androidx.wear.protolayout.material.Typography
import androidx.wear.protolayout.material.layouts.PrimaryLayout
import androidx.wear.tiles.RequestBuilders
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.splash.SplashActivity
import io.homeassistant.companion.android.common.R as commonR
const val RESOURCE_REFRESH = "refresh"
const val MODIFIER_CLICK_REFRESH = "refresh"
/** Performs a [VibrationEffect.EFFECT_CLICK] or equivalent on older Android versions */
fun hapticClick(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService<VibratorManager>()
val vibrator = vibratorManager?.defaultVibrator
vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
} else {
val vibrator = context.getSystemService<Vibrator>()
@Suppress("DEPRECATION")
vibrator?.vibrate(200)
}
}
/**
* A [Timeline] with a single entry, asking the user to log in to the app to start using the tile
* with a button to open the app. The tile is using the 'Dialog' style.
*/
fun loggedOutTimeline(
context: Context,
requestParams: RequestBuilders.TileRequest,
@StringRes title: Int,
@StringRes text: Int
): Timeline = primaryLayoutTimeline(
context = context,
requestParams = requestParams,
title = title,
text = text,
actionText = commonR.string.login,
action = ActionBuilders.LaunchAction.Builder()
.setAndroidActivity(
ActionBuilders.AndroidActivity.Builder()
.setClassName(SplashActivity::class.java.name)
.setPackageName(context.packageName)
.build()
).build()
)
/**
* A [Timeline] with a single entry using the Material `PrimaryLayout`. The title is optional.
*/
fun primaryLayoutTimeline(
context: Context,
requestParams: RequestBuilders.TileRequest,
@StringRes title: Int?,
@StringRes text: Int,
@StringRes actionText: Int,
action: ActionBuilders.Action
): Timeline {
val theme = Colors(
ContextCompat.getColor(context, R.color.colorPrimary), // Primary
ContextCompat.getColor(context, R.color.colorOnPrimary), // On primary
ContextCompat.getColor(context, R.color.colorOverlay), // Surface
ContextCompat.getColor(context, android.R.color.white) // On surface
)
val chipColors = ChipColors.primaryChipColors(theme)
val chipAction = ModifiersBuilders.Clickable.Builder()
.setId("action")
.setOnClick(action)
.build()
val builder = PrimaryLayout.Builder(requestParams.deviceConfiguration)
if (title != null) {
builder.setPrimaryLabelTextContent(
Text.Builder(context, context.getString(title))
.setTypography(Typography.TYPOGRAPHY_CAPTION1)
.setColor(argb(theme.primary))
.build()
)
}
builder.setContent(
Text.Builder(context, context.getString(text))
.setTypography(Typography.TYPOGRAPHY_BODY1)
.setMaxLines(10)
.setColor(argb(theme.onSurface))
.build()
)
builder.setPrimaryChipContent(
CompactChip.Builder(
context,
context.getString(actionText),
chipAction,
requestParams.deviceConfiguration
)
.setChipColors(chipColors)
.build()
)
return Timeline.fromLayoutElement(builder.build())
}
/**
* An [LayoutElementBuilders.Arc] with a refresh button at the bottom (centered). When added, it is
* expected that the TileService:
* - handles the refresh action ([MODIFIER_CLICK_REFRESH]) in `onTileRequest`;
* - adds a resource for [RESOURCE_REFRESH] in `onTileResourcesRequest`.
*/
fun getRefreshButton(): LayoutElementBuilders.Arc =
LayoutElementBuilders.Arc.Builder()
.setAnchorAngle(
DimensionBuilders.DegreesProp.Builder(180f).build()
)
.addContent(
LayoutElementBuilders.ArcAdapter.Builder()
.setContent(
LayoutElementBuilders.Image.Builder()
.setResourceId(RESOURCE_REFRESH)
.setWidth(DimensionBuilders.dp(24f))
.setHeight(DimensionBuilders.dp(24f))
.setModifiers(getRefreshModifiers())
.build()
)
.setRotateContents(false)
.build()
)
.build()
/** @return a modifier for tiles that represents a 'tap to refresh' [ActionBuilders.LoadAction] */
fun getRefreshModifiers(): ModifiersBuilders.Modifiers {
return ModifiersBuilders.Modifiers.Builder()
.setClickable(
ModifiersBuilders.Clickable.Builder()
.setOnClick(
ActionBuilders.LoadAction.Builder().build()
)
.setId(MODIFIER_CLICK_REFRESH)
.build()
)
.build()
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB