1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.car.docklib.view
18 
19 import android.content.res.ColorStateList
20 import android.graphics.ColorFilter
21 import android.graphics.PorterDuff
22 import android.graphics.PorterDuffColorFilter
23 import android.os.Build
24 import android.util.Log
25 import androidx.core.animation.Animator
26 import com.android.car.docklib.data.DockAppItem
27 import com.android.car.docklib.view.animation.ExcitementAnimationHelper
28 import com.google.android.material.imageview.ShapeableImageView
29 import java.util.EnumSet
30 import kotlin.math.floor
31 
32 /**
33  * Controller to help manage states for individual DockItemViews.
34  */
35 class DockItemViewController(
36     private val staticIconStrokeWidth: Float,
37     private val dynamicIconStrokeWidth: Float,
38     private val excitedIconStrokeWidth: Float,
39     private val staticIconStrokeColor: Int,
40     private val excitedIconStrokeColor: Int,
41     private val restrictedIconStrokeColor: Int,
42     private val defaultIconColor: Int,
43     private val excitedColorFilter: ColorFilter,
44     private val restrictedColorFilter: ColorFilter,
45     private val excitedIconColorFilterAlpha: Float,
46     private val exciteAnimationDuration: Int,
47 ) {
48 
49     companion object {
50         private val TAG = DockItemViewController::class.simpleName
51         private val DEBUG = Build.isDebuggable()
52         private const val DEFAULT_STROKE_WIDTH = 0f
53         private const val INITIAL_COLOR_FILTER_ALPHA = 0f
54     }
55 
56     private enum class TypeStates {
57         DYNAMIC, STATIC
58     }
59 
60     private enum class OptionalStates {
61         EXCITED, UPDATING, RESTRICTED, ACTIVE_MEDIA
62     }
63 
64     private var dynamicIconStrokeColor: Int = defaultIconColor
65     private var updatingColor: Int = defaultIconColor
66     private var exciteAnimator: Animator? = null
67 
68     private var typeState: Enum<TypeStates> = TypeStates.STATIC
69     private val optionalState: EnumSet<OptionalStates> = EnumSet.noneOf(OptionalStates::class.java)
70 
71     /**
72      * Setter to set if the DockItem is dynamic.
73      */
setDynamicnull74     fun setDynamic(dynamicIconStrokeColor: Int) {
75         typeState = TypeStates.DYNAMIC
76         this.dynamicIconStrokeColor = dynamicIconStrokeColor
77     }
78 
79     /**
80      * Setter to set if the DockItem is static.
81      */
setStaticnull82     fun setStatic() {
83         typeState = TypeStates.STATIC
84         this.dynamicIconStrokeColor = defaultIconColor
85     }
86 
87     /**
88      * Setter to set if the DockItem is excited. Returns true if the state was changed.
89      */
setExcitednull90     fun setExcited(isExcited: Boolean): Boolean {
91         if ((isExcited && optionalState.contains(OptionalStates.EXCITED)) ||
92             (!isExcited && !optionalState.contains(OptionalStates.EXCITED))
93         ) {
94             return false
95         }
96 
97         if (isExcited) {
98             optionalState.add(OptionalStates.EXCITED)
99         } else {
100             optionalState.remove(OptionalStates.EXCITED)
101         }
102         return true
103     }
104 
105     /**
106      * Setter to set if the DockItem is updating to another [DockAppItem]
107      * @param updatingColor color to use when app is updating. Generally the icon color of the next
108      * [DockAppItem]
109      * @return Returns true if the state was changed, false otherwise
110      */
setUpdatingnull111     fun setUpdating(isUpdating: Boolean, updatingColor: Int?): Boolean {
112         if ((isUpdating && optionalState.contains(OptionalStates.UPDATING)) ||
113             (!isUpdating && !optionalState.contains(OptionalStates.UPDATING))
114         ) {
115             return false
116         }
117 
118         if (isUpdating) {
119             optionalState.add(OptionalStates.UPDATING)
120         } else {
121             optionalState.remove(OptionalStates.UPDATING)
122         }
123         this.updatingColor = updatingColor ?: defaultIconColor
124         return true
125     }
126 
127     /**
128      * Setter to set if the DockItem is restricted. Returns true if the state was changed.
129      */
setRestrictednull130     fun setRestricted(isRestricted: Boolean): Boolean {
131         if ((isRestricted && optionalState.contains(OptionalStates.RESTRICTED)) ||
132             (!isRestricted && !optionalState.contains(OptionalStates.RESTRICTED))
133         ) {
134             return false
135         }
136 
137         if (isRestricted) {
138             optionalState.add(OptionalStates.RESTRICTED)
139         } else {
140             optionalState.remove(OptionalStates.RESTRICTED)
141         }
142         return true
143     }
144 
145     /** Tracks whether the app item has an active media session or not */
setHasActiveMediaSessionnull146     fun setHasActiveMediaSession(
147         hasMediaSession: Boolean
148     ): Boolean {
149         if ((hasMediaSession && optionalState.contains(OptionalStates.ACTIVE_MEDIA)) ||
150             (!hasMediaSession && !optionalState.contains(OptionalStates.ACTIVE_MEDIA))
151         ) {
152             return false
153         }
154 
155         if (hasMediaSession) {
156             optionalState.add(OptionalStates.ACTIVE_MEDIA)
157         } else {
158             optionalState.remove(OptionalStates.ACTIVE_MEDIA)
159         }
160         return true
161     }
162 
163     /** @return whether the view should be restricted or not */
shouldBeRestrictednull164     fun shouldBeRestricted(): Boolean {
165         return optionalState.contains(OptionalStates.RESTRICTED) &&
166                 !optionalState.contains(OptionalStates.ACTIVE_MEDIA)
167     }
168 
169     /**
170      * Updates the [appIcon] based on the current state
171      */
updateViewBasedOnStatenull172     fun updateViewBasedOnState(appIcon: ShapeableImageView) {
173         if (DEBUG) {
174             Log.d(
175                 TAG,
176                 "updateViewBasedOnState, typeState: $typeState, optionalState: $optionalState"
177             )
178         }
179         if (exciteAnimator != null) {
180             exciteAnimator?.cancel()
181             exciteAnimator = null
182         }
183         appIcon.strokeColor = ColorStateList.valueOf(getStrokeColor())
184         appIcon.strokeWidth = getStrokeWidth()
185         appIcon.colorFilter = getColorFilter()
186         val cp = getContentPadding()
187         // ContentPadding should not be set before the measure phase of the view otherwise it might
188         // set incorrect padding values on the view.
189         appIcon.post { appIcon.setContentPadding(cp, cp, cp, cp) }
190     }
191 
192     /**
193      * Animate the [appIcon] to be excited or reset after being excited.
194      */
animateAppIconExcitednull195     fun animateAppIconExcited(appIcon: ShapeableImageView) {
196         val isAnimationOngoing = exciteAnimator?.isRunning ?: false
197         if (DEBUG) {
198             Log.d(
199                 TAG,
200                 "Excite animation{ " +
201                         "isExciting: ${optionalState.contains(OptionalStates.EXCITED)}, " +
202                         "isAnimationOngoing: $isAnimationOngoing }"
203             )
204         }
205         exciteAnimator?.cancel()
206 
207         val toStrokeWidth: Float = getStrokeWidth()
208         val toContentPadding: Int = getContentPadding()
209         val toStrokeColor: Int = getStrokeColor()
210         val toColorFilterAlpha: Float = if (optionalState.contains(OptionalStates.EXCITED)) {
211             excitedIconColorFilterAlpha
212         } else {
213             INITIAL_COLOR_FILTER_ALPHA
214         }
215 
216         val successCallback = {
217             exciteAnimator = null
218             updateViewBasedOnState(appIcon)
219         }
220 
221         val failureCallback = {
222             exciteAnimator = null
223             updateViewBasedOnState(appIcon)
224         }
225 
226         exciteAnimator = ExcitementAnimationHelper.getExcitementAnimator(
227             appIcon,
228             exciteAnimationDuration.toLong(),
229             toStrokeWidth,
230             toStrokeColor,
231             toContentPadding,
232             toColorFilterAlpha,
233             successCallback,
234             failureCallback
235         )
236         exciteAnimator?.start()
237     }
238 
getStrokeColornull239     private fun getStrokeColor(): Int {
240         if (optionalState.contains(OptionalStates.UPDATING)) {
241             return updatingColor
242         } else if (shouldBeRestricted()) {
243             return restrictedIconStrokeColor
244         } else if (optionalState.contains(OptionalStates.EXCITED)) {
245             return excitedIconStrokeColor
246         } else if (typeState == TypeStates.STATIC) {
247             return staticIconStrokeColor
248         } else if (typeState == TypeStates.DYNAMIC) {
249             return dynamicIconStrokeColor
250         }
251         return defaultIconColor
252     }
253 
getStrokeWidthnull254     private fun getStrokeWidth(): Float {
255         if (optionalState.contains(OptionalStates.EXCITED)) {
256             return excitedIconStrokeWidth
257         } else if (typeState == TypeStates.STATIC) {
258             return staticIconStrokeWidth
259         } else if (typeState == TypeStates.DYNAMIC) {
260             return dynamicIconStrokeWidth
261         }
262         return DEFAULT_STROKE_WIDTH
263     }
264 
getContentPaddingnull265     private fun getContentPadding(): Int {
266         return getContentPaddingFromStrokeWidth(getStrokeWidth())
267     }
268 
getColorFilternull269     private fun getColorFilter(): ColorFilter? {
270         if (optionalState.contains(OptionalStates.UPDATING)) {
271             return PorterDuffColorFilter(updatingColor, PorterDuff.Mode.SRC_OVER)
272         } else if (shouldBeRestricted()){
273             return restrictedColorFilter
274         } else if (optionalState.contains(OptionalStates.EXCITED)) {
275             return excitedColorFilter
276         }
277         return null
278     }
279 
getContentPaddingFromStrokeWidthnull280     private fun getContentPaddingFromStrokeWidth(strokeWidth: Float): Int =
281         floor(strokeWidth / 2).toInt()
282 }
283