1 /** <lambda>null2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 5 * in compliance with the License. You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software distributed under the License 10 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 11 * or implied. See the License for the specific language governing permissions and limitations under 12 * the License. 13 */ 14 package com.android.healthconnect.controller.datasources 15 16 import android.health.connect.HealthDataCategory 17 import android.os.Bundle 18 import android.view.View 19 import android.widget.AdapterView 20 import androidx.core.os.bundleOf 21 import androidx.fragment.app.activityViewModels 22 import androidx.navigation.fragment.findNavController 23 import androidx.preference.Preference 24 import androidx.preference.PreferenceGroup 25 import androidx.recyclerview.widget.RecyclerView 26 import com.android.healthconnect.controller.R 27 import com.android.healthconnect.controller.datasources.DataSourcesViewModel.AggregationCardsState 28 import com.android.healthconnect.controller.datasources.DataSourcesViewModel.PotentialAppSourcesState 29 import com.android.healthconnect.controller.datasources.DataSourcesViewModel.PriorityListState 30 import com.android.healthconnect.controller.datasources.appsources.AppSourcesPreferenceCategory 31 import com.android.healthconnect.controller.navigation.CATEGORY_KEY 32 import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.lowercaseTitle 33 import com.android.healthconnect.controller.shared.HealthDataCategoryExtensions.uppercaseTitle 34 import com.android.healthconnect.controller.shared.HealthDataCategoryInt 35 import com.android.healthconnect.controller.shared.app.AppMetadata 36 import com.android.healthconnect.controller.shared.app.AppUtils 37 import com.android.healthconnect.controller.shared.preference.CardContainerPreference 38 import com.android.healthconnect.controller.shared.preference.HealthPreferenceFragment 39 import com.android.healthconnect.controller.shared.preference.buttonPreference 40 import com.android.healthconnect.controller.shared.preference.topIntroPreference 41 import com.android.healthconnect.controller.utils.AttributeResolver 42 import com.android.healthconnect.controller.utils.DeviceInfoUtilsImpl 43 import com.android.healthconnect.controller.utils.TimeSource 44 import com.android.healthconnect.controller.utils.logging.DataSourcesElement 45 import com.android.healthconnect.controller.utils.logging.HealthConnectLogger 46 import com.android.healthconnect.controller.utils.logging.PageName 47 import com.android.healthconnect.controller.utils.pref 48 import com.android.settingslib.widget.FooterPreference 49 import com.android.settingslib.widget.SettingsSpinnerAdapter 50 import com.android.settingslib.widget.SettingsSpinnerPreference 51 import com.android.settingslib.widget.SettingsThemeHelper 52 import com.android.settingslib.widget.ZeroStatePreference 53 import dagger.hilt.android.AndroidEntryPoint 54 import javax.inject.Inject 55 56 @AndroidEntryPoint(HealthPreferenceFragment::class) 57 class DataSourcesFragment : Hilt_DataSourcesFragment() { 58 59 companion object { 60 private const val DATA_TYPE_SPINNER_PREFERENCE_GROUP = "data_type_spinner_group" 61 private const val DATA_TOTALS_PREFERENCE_GROUP = "data_totals_group" 62 private const val DATA_TOTALS_PREFERENCE_KEY = "data_totals_preference" 63 private const val APP_SOURCES_PREFERENCE_GROUP = "app_sources_group" 64 private const val APP_SOURCES_PREFERENCE_KEY = "app_sources" 65 private const val ZERO_STATE_PREFERENCE_KEY = "zero_state" 66 private const val ADD_AN_APP_PREFERENCE_KEY = "add_an_app" 67 private const val NON_EMPTY_FOOTER_PREFERENCE_KEY = "data_sources_footer" 68 private const val EMPTY_STATE_HEADER_PREFERENCE_KEY = "empty_state_header" 69 private const val EMPTY_STATE_FOOTER_PREFERENCE_KEY = "empty_state_footer" 70 71 private val dataSourcesCategories = 72 arrayListOf(HealthDataCategory.ACTIVITY, HealthDataCategory.SLEEP) 73 } 74 75 init { 76 this.setPageName(PageName.DATA_SOURCES_PAGE) 77 } 78 79 @Inject lateinit var logger: HealthConnectLogger 80 @Inject lateinit var appUtils: AppUtils 81 82 private val dataSourcesViewModel: DataSourcesViewModel by activityViewModels() 83 private lateinit var spinnerPreference: SettingsSpinnerPreference 84 private lateinit var dataSourcesCategoriesStrings: List<String> 85 private var currentCategorySelection: @HealthDataCategoryInt Int = HealthDataCategory.ACTIVITY 86 @Inject lateinit var timeSource: TimeSource 87 88 private val dataTypeSpinnerPreferenceGroup: PreferenceGroup by 89 pref(DATA_TYPE_SPINNER_PREFERENCE_GROUP) 90 91 private val dataTotalsPreferenceGroup: PreferenceGroup by pref(DATA_TOTALS_PREFERENCE_GROUP) 92 93 private val zeroStatePreference: ZeroStatePreference by pref(ZERO_STATE_PREFERENCE_KEY) 94 95 private val appSourcesPreferenceGroup: PreferenceGroup by pref(APP_SOURCES_PREFERENCE_GROUP) 96 97 private val nonEmptyFooterPreference: FooterPreference by pref(NON_EMPTY_FOOTER_PREFERENCE_KEY) 98 99 private var cardContainerPreference: CardContainerPreference? = null 100 101 override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { 102 super.onCreatePreferences(savedInstanceState, rootKey) 103 setPreferencesFromResource(R.xml.data_sources_and_priority_screen, rootKey) 104 dataSourcesCategoriesStrings = 105 dataSourcesCategories.map { category -> getString(category.uppercaseTitle()) } 106 107 setupSpinnerPreference() 108 } 109 110 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 111 super.onViewCreated(view, savedInstanceState) 112 113 setLoading(true) 114 val currentStringSelection = spinnerPreference.selectedItem 115 currentCategorySelection = 116 dataSourcesCategories[dataSourcesCategoriesStrings.indexOf(currentStringSelection)] 117 dataSourcesViewModel.loadData(currentCategorySelection) 118 119 dataSourcesViewModel.dataSourcesAndAggregationsInfo.observe(viewLifecycleOwner) { 120 dataSourcesInfo -> 121 if (dataSourcesInfo.isLoading()) { 122 setLoading(true) 123 } else if (dataSourcesInfo.isLoadingFailed()) { 124 setLoading(false) 125 setError(true) 126 } else if (dataSourcesInfo.isWithData()) { 127 setLoading(false) 128 129 val priorityList = 130 (dataSourcesInfo.priorityListState as PriorityListState.WithData).priorityList 131 val potentialAppSources = 132 (dataSourcesInfo.potentialAppSourcesState as PotentialAppSourcesState.WithData) 133 .appSources 134 val cardInfos = 135 (dataSourcesInfo.aggregationCardsState as AggregationCardsState.WithData) 136 .dataTotals 137 138 if (priorityList.isEmpty() && potentialAppSources.isEmpty()) { 139 addEmptyState() 140 } else { 141 updateAppSourcesSection(potentialAppSources) 142 updateDataTotalsSection(cardInfos) 143 } 144 } 145 } 146 147 dataSourcesViewModel.updatedAggregationCardsData.observe(viewLifecycleOwner) { 148 aggregationCardsData -> 149 when (aggregationCardsData) { 150 is AggregationCardsState.Loading -> { 151 updateAggregations(listOf(), true) 152 } 153 is AggregationCardsState.LoadingFailed -> { 154 updateDataTotalsSection(listOf()) 155 } 156 is AggregationCardsState.WithData -> { 157 updateAggregations(aggregationCardsData.dataTotals, false) 158 } 159 } 160 } 161 162 dataSourcesViewModel.shouldShowAddAnAppButton.observe(viewLifecycleOwner) { 163 showAddAnAppButton -> 164 if (showAddAnAppButton) { 165 updateAddApp(true) 166 } 167 } 168 169 // Prevents incorrect item indexing in the priority list item content descriptions. 170 val recyclerView = view.findViewById<RecyclerView>(androidx.preference.R.id.recycler_view) 171 recyclerView?.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO 172 } 173 174 override fun onResume() { 175 super.onResume() 176 dataSourcesViewModel.loadData(currentCategorySelection) 177 } 178 179 /** Updates the priority list preference. */ 180 private fun updateAppSourcesSection(potentialAppSources: List<AppMetadata>) { 181 removeEmptyState() 182 appSourcesPreferenceGroup.isVisible = true 183 appSourcesPreferenceGroup.removePreferenceRecursively(APP_SOURCES_PREFERENCE_KEY) 184 185 appSourcesPreferenceGroup.addPreference( 186 AppSourcesPreferenceCategory( 187 requireContext(), 188 logger, 189 appUtils, 190 dataSourcesViewModel, 191 currentCategorySelection, 192 ) 193 .also { it.key = APP_SOURCES_PREFERENCE_KEY } 194 ) 195 196 updateAddApp(potentialAppSources.isNotEmpty()) 197 nonEmptyFooterPreference.isVisible = true 198 } 199 200 /** 201 * Shows the "Add an app" button when there is at least one potential app for the priority list. 202 * 203 * <p> Hides the button when there are no other potential apps for the priority list. 204 */ 205 private fun updateAddApp(shouldShow: Boolean) { 206 val button = appSourcesPreferenceGroup.findPreference<Preference>(ADD_AN_APP_PREFERENCE_KEY) 207 val currentVisibility = button?.isVisible ?: false 208 if (currentVisibility == shouldShow) { 209 return 210 } 211 212 appSourcesPreferenceGroup.removePreferenceRecursively(ADD_AN_APP_PREFERENCE_KEY) 213 214 if (!shouldShow) { 215 return 216 } 217 218 appSourcesPreferenceGroup.addPreference( 219 buttonPreference( 220 context = requireContext(), 221 icon = AttributeResolver.getDrawable(requireContext(), R.attr.addIcon), 222 title = getString(R.string.data_sources_add_app), 223 logName = DataSourcesElement.ADD_AN_APP_BUTTON, 224 key = ADD_AN_APP_PREFERENCE_KEY, 225 order = 226 100 /* Arbitrary number to ensure the button is added at the end of the priority list */, 227 listener = { 228 findNavController() 229 .navigate( 230 R.id.action_dataSourcesFragment_to_addAnAppFragment, 231 bundleOf(CATEGORY_KEY to currentCategorySelection), 232 ) 233 true 234 }, 235 ) 236 ) 237 } 238 239 /** Populates the data totals section with aggregation cards if needed. */ 240 private fun updateDataTotalsSection(cardInfos: List<AggregationCardInfo>) { 241 dataTotalsPreferenceGroup.removePreferenceRecursively(DATA_TOTALS_PREFERENCE_KEY) 242 // Do not show data cards when there are no apps on the priority list 243 if (!appSourcesPreferenceGroup.isVisible) { 244 return 245 } 246 247 if (cardInfos.isEmpty()) { 248 dataTotalsPreferenceGroup.isVisible = false 249 } else { 250 dataTotalsPreferenceGroup.isVisible = true 251 cardContainerPreference = 252 CardContainerPreference(requireContext(), timeSource).also { 253 it.setAggregationCardInfo(cardInfos) 254 it.key = DATA_TOTALS_PREFERENCE_KEY 255 } 256 dataTotalsPreferenceGroup.addPreference( 257 (cardContainerPreference as CardContainerPreference) 258 ) 259 } 260 } 261 262 /** Updates the aggregation cards after a priority list change. */ 263 private fun updateAggregations(cardInfos: List<AggregationCardInfo>, isLoading: Boolean) { 264 if (isLoading) { 265 cardContainerPreference?.setLoading(true) 266 } else { 267 if (cardInfos.isEmpty()) { 268 dataTotalsPreferenceGroup.isVisible = false 269 } else { 270 dataTotalsPreferenceGroup.isVisible = true 271 cardContainerPreference?.setAggregationCardInfo(cardInfos) 272 cardContainerPreference?.setLoading(false) 273 } 274 } 275 } 276 277 /** 278 * The empty state of this fragment is represented by: 279 * - no apps with write permissions for this category 280 * - no apps with data for this category 281 */ 282 private fun addEmptyState() { 283 removeNonEmptyState() 284 removeEmptyState() 285 286 addEmptyHeader() 287 preferenceScreen.addPreference(getEmptyStateFooterPreference()) 288 } 289 290 private fun addEmptyHeader() { 291 if (SettingsThemeHelper.isExpressiveTheme(requireContext())) { 292 zeroStatePreference.isVisible = true 293 } else { 294 preferenceScreen.addPreference(getEmptyStateHeaderPreference()) 295 } 296 } 297 298 private fun removeEmptyState() { 299 preferenceScreen.removePreferenceRecursively(EMPTY_STATE_HEADER_PREFERENCE_KEY) 300 preferenceScreen.removePreferenceRecursively(EMPTY_STATE_FOOTER_PREFERENCE_KEY) 301 zeroStatePreference.isVisible = false 302 } 303 304 private fun removeNonEmptyState() { 305 preferenceScreen.removePreferenceRecursively(APP_SOURCES_PREFERENCE_KEY) 306 preferenceScreen.removePreferenceRecursively(ADD_AN_APP_PREFERENCE_KEY) 307 preferenceScreen.removePreferenceRecursively(DATA_TOTALS_PREFERENCE_KEY) 308 309 // We hide the preference group headers and footer instead of removing them 310 appSourcesPreferenceGroup.isVisible = false 311 dataTotalsPreferenceGroup.isVisible = false 312 nonEmptyFooterPreference.isVisible = false 313 } 314 315 private fun getEmptyStateHeaderPreference(): Preference { 316 return topIntroPreference( 317 context = requireContext(), 318 preferenceTitle = getString(R.string.data_sources_empty_state), 319 preferenceKey = EMPTY_STATE_HEADER_PREFERENCE_KEY, 320 ) 321 } 322 323 private fun getEmptyStateFooterPreference(): FooterPreference { 324 return FooterPreference(context).also { 325 it.title = 326 getString( 327 R.string.data_sources_empty_state_footer, 328 getString(currentCategorySelection.lowercaseTitle()), 329 ) 330 it.setLearnMoreText(getString(R.string.data_sources_help_link)) 331 it.setLearnMoreAction { DeviceInfoUtilsImpl().openHCGetStartedLink(requireActivity()) } 332 it.key = EMPTY_STATE_FOOTER_PREFERENCE_KEY 333 } 334 } 335 336 private fun setupSpinnerPreference() { 337 spinnerPreference = SettingsSpinnerPreference(requireContext()) 338 spinnerPreference.setAdapter( 339 SettingsSpinnerAdapter<String>(context).also { it.addAll(dataSourcesCategoriesStrings) } 340 ) 341 342 spinnerPreference.setOnItemSelectedListener( 343 object : AdapterView.OnItemSelectedListener { 344 override fun onItemSelected( 345 parent: AdapterView<*>?, 346 view: View?, 347 position: Int, 348 id: Long, 349 ) { 350 logger.logInteraction(DataSourcesElement.DATA_TYPE_SPINNER) 351 352 val currentCategory = dataSourcesCategories[position] 353 currentCategorySelection = dataSourcesCategories[position] 354 355 // Reload the data sources information when a new category has been selected 356 dataSourcesViewModel.loadData(currentCategory) 357 dataSourcesViewModel.setCurrentSelection(currentCategory) 358 } 359 360 override fun onNothingSelected(p0: AdapterView<*>?) {} 361 } 362 ) 363 364 spinnerPreference.setSelection( 365 dataSourcesCategories.indexOf(dataSourcesViewModel.getCurrentSelection()) 366 ) 367 368 dataTypeSpinnerPreferenceGroup.isVisible = true 369 dataTypeSpinnerPreferenceGroup.addPreference(spinnerPreference) 370 logger.logImpression(DataSourcesElement.DATA_TYPE_SPINNER) 371 } 372 } 373