1 /**
2  * 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.shared.preference
15 
16 import android.content.Context
17 import android.view.LayoutInflater
18 import android.view.View
19 import android.widget.TextView
20 import androidx.constraintlayout.widget.ConstraintLayout
21 import androidx.constraintlayout.widget.ConstraintSet
22 import androidx.preference.Preference
23 import androidx.preference.PreferenceViewHolder
24 import com.android.healthconnect.controller.R
25 import com.android.healthconnect.controller.datasources.AggregationCardInfo
26 import com.android.healthconnect.controller.permissions.connectedapps.ComparablePreference
27 import com.android.healthconnect.controller.utils.SystemTimeSource
28 import com.android.healthconnect.controller.utils.TimeSource
29 import com.android.healthconnect.controller.utils.logging.DataSourcesElement
30 import com.android.healthconnect.controller.utils.logging.ElementName
31 import com.android.healthconnect.controller.utils.logging.HealthConnectLogger
32 import com.android.healthconnect.controller.utils.logging.HealthConnectLoggerEntryPoint
33 import dagger.hilt.android.EntryPointAccessors
34 
35 class CardContainerPreference
36 constructor(context: Context, private val timeSource: TimeSource = SystemTimeSource) :
37     Preference(context), ComparablePreference {
38 
39     private var logger: HealthConnectLogger
40     var logName: ElementName = DataSourcesElement.DATA_TOTALS_CARD
41 
42     init {
43         layoutResource = R.layout.widget_card_preference
44         isSelectable = false
45         val hiltEntryPoint =
46             EntryPointAccessors.fromApplication(
47                 context.applicationContext, HealthConnectLoggerEntryPoint::class.java)
48         logger = hiltEntryPoint.logger()
49     }
50 
51     private val mAggregationCardInfo: MutableList<AggregationCardInfo> = mutableListOf()
52     private var container: ConstraintLayout? = null
53     private var holder: PreferenceViewHolder? = null
54     private var isLoading = false
55     private var progressBar: ConstraintLayout? = null
56 
setAggregationCardInfonull57     fun setAggregationCardInfo(aggregationCardInfoList: List<AggregationCardInfo>) {
58         mAggregationCardInfo.clear()
59 
60         if (aggregationCardInfoList.isEmpty()) {
61             return
62         }
63         // We display a max of 2 cards, so we take the first two list items
64         if (aggregationCardInfoList.size > 2) {
65             this.mAggregationCardInfo.addAll(aggregationCardInfoList.subList(0, 2))
66         } else {
67             this.mAggregationCardInfo.addAll(aggregationCardInfoList)
68         }
69     }
70 
setLoadingnull71     fun setLoading(isLoading: Boolean) {
72         this.isLoading = isLoading
73         if (container == null) {
74             return
75         }
76 
77         if (!isLoading) {
78             holder?.let { onBindViewHolder(it) }
79         } else {
80             // Get the current width and height on the card container so we don't flash the screen
81             val width = container?.width
82             val height = container?.height
83 
84             container?.removeAllViews()
85             val layoutInflater = LayoutInflater.from(context)
86             progressBar =
87                 layoutInflater.inflate(R.layout.widget_loading_preference, null) as ConstraintLayout
88 
89             val layoutParams =
90                 ConstraintLayout.LayoutParams(
91                     width ?: ConstraintLayout.LayoutParams.WRAP_CONTENT,
92                     height ?: ConstraintLayout.LayoutParams.WRAP_CONTENT)
93             progressBar?.layoutParams = layoutParams
94             container?.addView(progressBar)
95         }
96     }
97 
onBindViewHoldernull98     override fun onBindViewHolder(holder: PreferenceViewHolder) {
99         super.onBindViewHolder(holder)
100         this.holder = holder
101         container = holder.itemView as ConstraintLayout
102 
103         if (!isLoading) {
104             setupCards()
105         } else {
106             setLoading(true)
107         }
108 
109         logger.logImpression(logName)
110     }
111 
setupCardsnull112     private fun setupCards() {
113 
114         if (container == null) {
115             return
116         }
117 
118         if (this.mAggregationCardInfo.isEmpty() || this.mAggregationCardInfo.size > 2) {
119             return
120         }
121 
122         if (mAggregationCardInfo.size == 1) {
123             val card = addSingleLargeCard(mAggregationCardInfo[0])
124             removeAllChildrenExcept(container, card)
125         } else {
126 
127             // Add both types of cards to the container (they will be invisible)
128             val (firstSmallCard, secondSmallCard) =
129                 addTwoSmallCards(mAggregationCardInfo[0], mAggregationCardInfo[1])
130 
131             val (firstLargeCard, secondLargeCard) =
132                 addTwoLargeCards(mAggregationCardInfo[0], mAggregationCardInfo[1])
133 
134             val firstCardText = firstSmallCard.findViewById<TextView>(R.id.card_title_number)
135             val secondCardText = secondSmallCard.findViewById<TextView>(R.id.card_title_number)
136             val firstCardDate = firstSmallCard.findViewById<TextView>(R.id.card_date)
137             val secondCardDate = secondSmallCard.findViewById<TextView>(R.id.card_date)
138 
139             // Check for the ellipsized text after the first card has been drawn
140             // If there is ellipsized text, remove the small cards and set the large cards to
141             // visible
142             // If there is no ellipsized text, remove the large cards and set the small cards to
143             // visible
144             firstSmallCard.post {
145                 if (isTextEllipsized(firstCardText) ||
146                     isTextEllipsized(secondCardText) ||
147                     isTextEllipsized(firstCardDate) ||
148                     isTextEllipsized(secondCardDate)) {
149                     container?.removeView(firstSmallCard)
150                     container?.removeView(secondSmallCard)
151                     container?.removeView(progressBar)
152                     firstLargeCard.visibility = View.VISIBLE
153                     secondLargeCard.visibility = View.VISIBLE
154                 } else {
155                     container?.removeView(firstLargeCard)
156                     container?.removeView(secondLargeCard)
157                     container?.removeView(progressBar)
158                     firstSmallCard.visibility = View.VISIBLE
159                     secondSmallCard.visibility = View.VISIBLE
160                 }
161             }
162         }
163     }
164 
165     /**
166      * Adds a single large [AggregationDataCard] to the provided container. This should be called
167      * when there is only one available aggregate.
168      */
addSingleLargeCardnull169     private fun addSingleLargeCard(cardInfo: AggregationCardInfo): AggregationDataCard {
170         val singleCard =
171             AggregationDataCard(
172                 context, null, AggregationDataCard.CardTypeEnum.LARGE_CARD, cardInfo, timeSource)
173         singleCard.id = View.generateViewId()
174         val layoutParams =
175             ConstraintLayout.LayoutParams(
176                 ConstraintLayout.LayoutParams.MATCH_PARENT,
177                 ConstraintLayout.LayoutParams.WRAP_CONTENT)
178         singleCard.layoutParams = layoutParams
179         container?.addView(singleCard)
180         return singleCard
181     }
182 
183     /**
184      * Adds two small [AggregationDataCard]s to the provided container stacked horizontally. This
185      * should be called when there are two available aggregates.
186      */
addTwoSmallCardsnull187     private fun addTwoSmallCards(
188         firstCardInfo: AggregationCardInfo,
189         secondCardInfo: AggregationCardInfo
190     ): Pair<AggregationDataCard, AggregationDataCard> {
191         // Construct the first card
192         val firstCard = constructSmallCard(firstCardInfo, addMargin = true)
193 
194         // Construct the second card
195         val secondCard = constructSmallCard(secondCardInfo, addMargin = false)
196 
197         firstCard.visibility = View.INVISIBLE
198         secondCard.visibility = View.INVISIBLE
199         container?.addView(firstCard)
200         container?.addView(secondCard)
201 
202         applySmallCardConstraints(firstCard, secondCard)
203 
204         return Pair(firstCard, secondCard)
205     }
206 
applySmallCardConstraintsnull207     private fun applySmallCardConstraints(
208         firstCard: AggregationDataCard,
209         secondCard: AggregationDataCard
210     ) {
211         // Add the constraints between the two cards in their ConstraintLayout container
212         val constraintSet = ConstraintSet()
213         constraintSet.clone(container)
214 
215         // Constraints for the first card
216         constraintSet.connect(
217             firstCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
218         constraintSet.connect(
219             firstCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
220         constraintSet.connect(
221             firstCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
222         constraintSet.connect(firstCard.id, ConstraintSet.END, secondCard.id, ConstraintSet.START)
223 
224         // Constraints for the second card
225         constraintSet.connect(secondCard.id, ConstraintSet.START, firstCard.id, ConstraintSet.END)
226         constraintSet.connect(
227             secondCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
228         constraintSet.connect(
229             secondCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
230         constraintSet.connect(
231             secondCard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
232 
233         constraintSet.applyTo(container)
234     }
235 
constructSmallCardnull236     private fun constructSmallCard(
237         cardInfo: AggregationCardInfo,
238         addMargin: Boolean
239     ): AggregationDataCard {
240         val card =
241             AggregationDataCard(
242                 context, null, AggregationDataCard.CardTypeEnum.SMALL_CARD, cardInfo, timeSource)
243         card.id = View.generateViewId()
244         val layoutParams =
245             ConstraintLayout.LayoutParams(0, ConstraintLayout.LayoutParams.WRAP_CONTENT)
246 
247         if (addMargin) {
248             // Set a right margin of 16dp for the first (leftmost) card
249             val marginInDp = 16
250             val marginInPx = (marginInDp * context.resources.displayMetrics.density).toInt()
251             layoutParams.setMargins(0, 0, marginInPx, 0)
252         }
253 
254         card.layoutParams = layoutParams
255 
256         return card
257     }
258 
259     /**
260      * Adds two large [AggregationDataCard]s to the provided container stacked vertically. This
261      * should be called when there are two available aggregates and the text is too large to fit
262      * into small cards.
263      */
addTwoLargeCardsnull264     private fun addTwoLargeCards(
265         firstCardInfo: AggregationCardInfo,
266         secondCardInfo: AggregationCardInfo
267     ): Pair<AggregationDataCard, AggregationDataCard> {
268         // Construct the first card
269         val firstLongCard = constructLargeCard(firstCardInfo, addMargin = true)
270         // Construct the second card
271         val secondLongCard = constructLargeCard(secondCardInfo, addMargin = false)
272 
273         firstLongCard.visibility = View.GONE
274         secondLongCard.visibility = View.GONE
275 
276         container?.addView(firstLongCard)
277         container?.addView(secondLongCard)
278 
279         applyLargeCardConstraints(firstLongCard, secondLongCard)
280 
281         return Pair(firstLongCard, secondLongCard)
282     }
283 
applyLargeCardConstraintsnull284     private fun applyLargeCardConstraints(
285         firstCard: AggregationDataCard,
286         secondCard: AggregationDataCard
287     ) {
288         // Add the constraints between the two cards in their ConstraintLayout container
289         val constraintSet = ConstraintSet()
290         constraintSet.clone(container)
291 
292         // Constraints for the first card
293         constraintSet.connect(
294             firstCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
295         constraintSet.connect(
296             firstCard.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP)
297         constraintSet.connect(firstCard.id, ConstraintSet.BOTTOM, secondCard.id, ConstraintSet.TOP)
298         constraintSet.connect(
299             firstCard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
300 
301         // Constraints for the first card
302         constraintSet.connect(secondCard.id, ConstraintSet.TOP, firstCard.id, ConstraintSet.BOTTOM)
303         constraintSet.connect(
304             secondCard.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START)
305         constraintSet.connect(
306             secondCard.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM)
307         constraintSet.connect(
308             secondCard.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END)
309 
310         constraintSet.applyTo(container)
311     }
312 
constructLargeCardnull313     private fun constructLargeCard(
314         cardInfo: AggregationCardInfo,
315         addMargin: Boolean
316     ): AggregationDataCard {
317         val largeCard =
318             AggregationDataCard(
319                 context, null, AggregationDataCard.CardTypeEnum.LARGE_CARD, cardInfo, timeSource)
320         largeCard.id = View.generateViewId()
321 
322         val layoutParams =
323             ConstraintLayout.LayoutParams(0, ConstraintLayout.LayoutParams.WRAP_CONTENT)
324 
325         if (addMargin) {
326             // Set a bottom margin of 16dp for the first (topmost) card
327             val marginInDp = 16
328             val marginInPx = (marginInDp * context.resources.displayMetrics.density).toInt()
329             layoutParams.setMargins(0, 0, 0, marginInPx)
330         }
331 
332         largeCard.layoutParams = layoutParams
333         return largeCard
334     }
335 
336     /** Returns true if the provided textView is ellipsized (...) */
isTextEllipsizednull337     private fun isTextEllipsized(textView: TextView): Boolean {
338         if (textView.layout != null) {
339             val lines = textView.layout.lineCount
340             if (lines > 0) {
341                 if (textView.layout.getEllipsisCount(lines - 1) > 0) {
342                     return true
343                 }
344             }
345         }
346         return false
347     }
348 
removeAllChildrenExceptnull349     private fun removeAllChildrenExcept(container: ConstraintLayout?, childToKeep: View) {
350         container?.let {
351             for (i in it.childCount - 1 downTo 0) {
352                 val currentChild = it.getChildAt(i)
353                 if (currentChild != childToKeep) {
354                     it.removeViewAt(i)
355                 }
356             }
357         }
358     }
359 
hasSameContentsnull360     override fun hasSameContents(preference: Preference): Boolean {
361         return preference is CardContainerPreference &&
362             preference.mAggregationCardInfo == this.mAggregationCardInfo
363     }
364 
isSameItemnull365     override fun isSameItem(preference: Preference): Boolean {
366         return preference is CardContainerPreference && this == preference
367     }
368 }
369