Implement suggestion area in app settings (#3640)

Implement suggestion area in settings

 - Add a suggestion area to the top of the main app settings screen where very important settings can be promoted; to start setting HA as assistant app and enabling the notification permission
This commit is contained in:
Joris Pelgröm 2023-07-07 17:41:35 +02:00 committed by GitHub
parent e53533ed4a
commit 774e89c58e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 250 additions and 0 deletions

View file

@ -2,6 +2,7 @@ package io.homeassistant.companion.android.settings
import android.annotation.SuppressLint
import android.app.UiModeManager
import android.content.ComponentName
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
@ -107,6 +108,29 @@ class SettingsFragment(
}
}
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
presenter.getSuggestionFlow().collect { suggestion ->
findPreference<SettingsSuggestionPreference>("settings_suggestion")?.let {
if (suggestion != null) {
it.setTitle(suggestion.title)
it.setSummary(suggestion.summary)
it.setIcon(suggestion.icon)
it.setOnPreferenceClickListener {
when (suggestion.id) {
SettingsPresenter.SUGGESTION_ASSISTANT_APP -> updateAssistantApp()
SettingsPresenter.SUGGESTION_NOTIFICATION_PERMISSION -> openNotificationSettings()
}
return@setOnPreferenceClickListener true
}
it.setOnPreferenceCancelListener { presenter.cancelSuggestion(requireContext(), suggestion.id) }
}
it.isVisible = suggestion != null
}
}
}
}
findPreference<Preference>("server_add")?.let {
it.setOnPreferenceClickListener {
requestOnboardingResult.launch(
@ -320,6 +344,14 @@ class SettingsFragment(
}
}
private fun updateAssistantApp() {
// On Android Q+, this is a workaround as Android doesn't allow requesting the assistant role
val openIntent = Intent(Intent.ACTION_MAIN)
openIntent.component = ComponentName("com.android.settings", "com.android.settings.Settings\$ManageAssistActivity")
openIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(openIntent)
}
private fun updateBackgroundAccessPref() {
findPreference<Preference>("background")?.let {
if (isIgnoringBatteryOptimizations()) {
@ -482,6 +514,7 @@ class SettingsFragment(
override fun onResume() {
super.onResume()
activity?.title = getString(commonR.string.companion_app)
context?.let { presenter.updateSuggestions(it) }
}
override fun onDestroy() {

View file

@ -0,0 +1,11 @@
package io.homeassistant.companion.android.settings
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
data class SettingsHomeSuggestion(
val id: String,
@StringRes val title: Int,
@StringRes val summary: Int,
@DrawableRes val icon: Int
)

View file

@ -8,10 +8,18 @@ import io.homeassistant.companion.android.onboarding.OnboardApp
import kotlinx.coroutines.flow.StateFlow
interface SettingsPresenter {
companion object {
const val SUGGESTION_ASSISTANT_APP = "assistant_app"
const val SUGGESTION_NOTIFICATION_PERMISSION = "notification_permission"
}
fun init(view: SettingsView)
fun getPreferenceDataStore(): PreferenceDataStore
fun onFinish()
fun updateSuggestions(context: Context)
fun cancelSuggestion(context: Context, id: String)
suspend fun addServer(result: OnboardApp.Output?)
fun getSuggestionFlow(): StateFlow<SettingsHomeSuggestion?>
fun getServersFlow(): StateFlow<List<Server>>
fun getServerCount(): Int
suspend fun getNotificationRateLimits(): RateLimitResponse?

View file

@ -1,9 +1,15 @@
package io.homeassistant.companion.android.settings
import android.app.role.RoleManager
import android.content.Context
import android.os.Build
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.preference.PreferenceDataStore
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.data.integration.DeviceRegistration
import io.homeassistant.companion.android.common.data.integration.impl.entities.RateLimitResponse
import io.homeassistant.companion.android.common.data.prefs.PrefsRepository
@ -29,11 +35,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import javax.inject.Inject
import io.homeassistant.companion.android.common.R as commonR
class SettingsPresenterImpl @Inject constructor(
private val serverManager: ServerManager,
@ -53,6 +61,8 @@ class SettingsPresenterImpl @Inject constructor(
private lateinit var view: SettingsView
private var suggestionFlow = MutableStateFlow<SettingsHomeSuggestion?>(null)
override fun getBoolean(key: String, defValue: Boolean): Boolean = runBlocking {
return@runBlocking when (key) {
"fullscreen" -> prefsRepository.isFullScreenEnabled()
@ -111,6 +121,8 @@ class SettingsPresenterImpl @Inject constructor(
mainScope.cancel()
}
override fun getSuggestionFlow(): StateFlow<SettingsHomeSuggestion?> = suggestionFlow
override fun getServersFlow(): StateFlow<List<Server>> = serverManager.defaultServersFlow
override fun getServerCount(): Int = serverManager.defaultServers.size
@ -206,4 +218,58 @@ class SettingsPresenterImpl @Inject constructor(
)
}
}
override fun updateSuggestions(context: Context) {
mainScope.launch { getSuggestions(context, false) }
}
override fun cancelSuggestion(context: Context, id: String) {
mainScope.launch {
val ignored = prefsRepository.getIgnoredSuggestions()
if (!ignored.contains(id)) {
prefsRepository.setIgnoredSuggestions(ignored + id)
}
getSuggestions(context, true)
}
}
private suspend fun getSuggestions(context: Context, overwrite: Boolean) {
val suggestions = mutableListOf<SettingsHomeSuggestion>()
// Assist
var assistantSuggestion = serverManager.defaultServers.any { it.version?.isAtLeast(2023, 5) == true }
if (assistantSuggestion && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val roleManager = context.getSystemService<RoleManager>()
assistantSuggestion = roleManager?.isRoleAvailable(RoleManager.ROLE_ASSISTANT) == true && !roleManager.isRoleHeld(RoleManager.ROLE_ASSISTANT)
} else if (assistantSuggestion && Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val defaultApp: String? = Settings.Secure.getString(context.contentResolver, "assistant")
assistantSuggestion = defaultApp?.contains(BuildConfig.APPLICATION_ID) == false
}
if (assistantSuggestion) {
suggestions += SettingsHomeSuggestion(
SettingsPresenter.SUGGESTION_ASSISTANT_APP,
commonR.string.suggestion_assist_title,
commonR.string.suggestion_assist_summary,
R.drawable.ic_comment_processing_outline
)
}
// Notifications
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !NotificationManagerCompat.from(context).areNotificationsEnabled()) {
suggestions += SettingsHomeSuggestion(
SettingsPresenter.SUGGESTION_NOTIFICATION_PERMISSION,
commonR.string.suggestion_notifications_title,
commonR.string.suggestion_notifications_summary,
commonR.drawable.ic_notifications
)
}
val ignored = prefsRepository.getIgnoredSuggestions()
val filteredSuggestions = suggestions.filter { !ignored.contains(it.id) }
if (overwrite || suggestionFlow.value == null) {
suggestionFlow.emit(filteredSuggestions.randomOrNull())
} else if (filteredSuggestions.none { it.id == suggestionFlow.value?.id }) {
suggestionFlow.emit(null)
}
}
}

View file

@ -0,0 +1,31 @@
package io.homeassistant.companion.android.settings
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageButton
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import io.homeassistant.companion.android.R
class SettingsSuggestionPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet,
defStyleAttr: Int = 0
) : Preference(context, attrs, defStyleAttr) {
private var onCancelClickListener: View.OnClickListener? = null
init {
layoutResource = R.layout.preference_suggestion
}
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
holder.itemView.findViewById<ImageButton>(R.id.cancel)?.setOnClickListener(onCancelClickListener)
}
fun setOnPreferenceCancelListener(listener: View.OnClickListener?) {
onCancelClickListener = listener
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/colorAccent"
android:pathData="M9,22A1,1 0 0,1 8,21V18H4A2,2 0 0,1 2,16V4C2,2.89 2.9,2 4,2H20A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H13.9L10.2,21.71C10,21.9 9.75,22 9.5,22V22H9M10,16V19.08L13.08,16H20V4H4V16H10M17,11H15V9H17V11M13,11H11V9H13V11M9,11H7V9H9V11Z" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/colorPreferenceSuggestion"/>
<corners android:radius="@dimen/bottom_sheet_corner_radius"/>
</shape>

View file

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="?android:attr/selectableItemBackground"
android:animateLayoutChanges="true"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:background="@drawable/preference_suggestion_background">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageView
android:id="@android:id/icon"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginBottom="4dp"
android:contentDescription="@null"
tools:src="@drawable/ic_comment_processing_outline"/>
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageButton
android:id="@+id/cancel"
android:layout_width="24dp"
android:layout_height="24dp"
android:padding="4dp"
android:background="?android:attr/selectableItemBackground"
android:src="@drawable/ic_clear_black"
android:scaleType="fitCenter"
android:contentDescription="@string/cancel"
app:tint="?attr/colorControlNormal" />
</LinearLayout>
<TextView
android:id="@android:id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.TitleMedium"
tools:text="@string/suggestion_assist_title"/>
<TextView
android:id="@android:id/summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodyMedium"
tools:text="@string/suggestion_assist_summary"/>
</LinearLayout>
</FrameLayout>

View file

@ -2,6 +2,9 @@
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<io.homeassistant.companion.android.settings.SettingsSuggestionPreference
android:key="settings_suggestion"
app:isPreferenceVisible="false" />
<PreferenceCategory
android:key="servers_devices_category"
android:title="@string/servers_devices_category">

View file

@ -62,4 +62,8 @@ interface PrefsRepository {
suspend fun saveKeyAlias(alias: String)
suspend fun getKeyAlias(): String?
suspend fun getIgnoredSuggestions(): List<String>
suspend fun setIgnoredSuggestions(ignored: List<String>)
}

View file

@ -30,6 +30,7 @@ class PrefsRepositoryImpl @Inject constructor(
private const val PREF_WEBVIEW_DEBUG_ENABLED = "webview_debug_enabled"
private const val PREF_KEY_ALIAS = "key-alias"
private const val PREF_CRASH_REPORTING_DISABLED = "crash_reporting"
private const val PREF_IGNORED_SUGGESTIONS = "ignored_suggestions"
}
init {
@ -188,4 +189,12 @@ class PrefsRepositoryImpl @Inject constructor(
override suspend fun getKeyAlias(): String? {
return localStorage.getString(PREF_KEY_ALIAS)
}
override suspend fun getIgnoredSuggestions(): List<String> {
return localStorage.getStringSet(PREF_IGNORED_SUGGESTIONS)?.toList() ?: emptyList()
}
override suspend fun setIgnoredSuggestions(ignored: List<String>) {
localStorage.putStringSet(PREF_IGNORED_SUGGESTIONS, ignored.toSet())
}
}

View file

@ -34,6 +34,7 @@
<color name="colorDeviceControlsThermostatHeat">#FF8B66</color>
<color name="colorDeviceControlsCamera">#F1F3F4</color>
<color name="colorSpeechText">#B3E5FC</color>
<color name="colorPreferenceSuggestion">#1F03A9F4</color> <!-- colorAccent 12% opacity -->
<color name="colorOnSurfaceVariant">#49454E</color> <!-- M3 On Surface Variant -->
<color name="colorBottomSheetHandle">#6649454E</color> <!-- M3 On Surface Variant 40% opacity -->
</resources>

View file

@ -768,6 +768,10 @@
<string name="store_request_successful">Request to install app on wear device sent successfully</string>
<string name="store_request_unsuccessful">Play Store Request Failed. Wear device(s) may not support Play Store, that is, the Wear device may be version 1.0.</string>
<string name="successful">Successful</string>
<string name="suggestion_assist_title">Use Assist from anywhere</string>
<string name="suggestion_assist_summary">Set Home Assistant as your assistant app</string>
<string name="suggestion_notifications_title">Enable notifications</string>
<string name="suggestion_notifications_summary">Allow Home Assistant to send notifications</string>
<string name="sun">Sun</string>
<string name="switches">Switches</string>
<string name="tag_reader_title">Processing Tag</string>