• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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