mirror of
https://github.com/home-assistant/android
synced 2024-10-02 22:34:46 +00:00
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:
parent
8d4822a23d
commit
2c20baf0f5
|
@ -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
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]!!
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 = {})
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
}
|
BIN
wear/src/main/res/drawable-round/camera_tile_example.png
Normal file
BIN
wear/src/main/res/drawable-round/camera_tile_example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.5 KiB |
BIN
wear/src/main/res/drawable/camera_tile_example.png
Normal file
BIN
wear/src/main/res/drawable/camera_tile_example.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
Loading…
Reference in a new issue