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