1 /*
<lambda>null2  * Copyright (C) 2021 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 package com.android.systemui.shared.clocks
17 
18 import android.animation.TimeInterpolator
19 import android.annotation.ColorInt
20 import android.annotation.IntRange
21 import android.annotation.SuppressLint
22 import android.content.Context
23 import android.graphics.Canvas
24 import android.text.Layout
25 import android.text.TextUtils
26 import android.text.format.DateFormat
27 import android.util.AttributeSet
28 import android.util.MathUtils.constrainedMap
29 import android.util.TypedValue.COMPLEX_UNIT_PX
30 import android.view.View
31 import android.view.View.MeasureSpec.EXACTLY
32 import android.widget.TextView
33 import com.android.app.animation.Interpolators
34 import com.android.internal.annotations.VisibleForTesting
35 import com.android.systemui.animation.GlyphCallback
36 import com.android.systemui.animation.TextAnimator
37 import com.android.systemui.animation.TypefaceVariantCacheImpl
38 import com.android.systemui.customization.R
39 import com.android.systemui.log.core.LogLevel
40 import com.android.systemui.log.core.LogcatOnlyMessageBuffer
41 import com.android.systemui.log.core.Logger
42 import com.android.systemui.log.core.MessageBuffer
43 import java.io.PrintWriter
44 import java.util.Calendar
45 import java.util.Locale
46 import java.util.TimeZone
47 import kotlin.math.min
48 
49 /**
50  * Displays the time with the hour positioned above the minutes (ie: 09 above 30 is 9:30). The
51  * time's text color is a gradient that changes its colors based on its controller.
52  */
53 @SuppressLint("AppCompatCustomView")
54 class AnimatableClockView
55 @JvmOverloads
56 constructor(
57     context: Context,
58     attrs: AttributeSet? = null,
59     defStyleAttr: Int = 0,
60     defStyleRes: Int = 0,
61 ) : TextView(context, attrs, defStyleAttr, defStyleRes) {
62     // To protect us from issues from this being null while the TextView constructor is running, we
63     // implement the get method and ensure a value is returned before initialization is complete.
64     private var logger = DEFAULT_LOGGER
65         get() = field ?: DEFAULT_LOGGER
66 
67     var messageBuffer: MessageBuffer
68         get() = logger.buffer
69         set(value) {
70             logger = Logger(value, TAG)
71         }
72 
73     var hasCustomPositionUpdatedAnimation: Boolean = false
74     var migratedClocks: Boolean = false
75 
76     private val time = Calendar.getInstance()
77 
78     private val dozingWeightInternal: Int
79     private val lockScreenWeightInternal: Int
80     private val isSingleLineInternal: Boolean
81 
82     private var format: CharSequence? = null
83     private var descFormat: CharSequence? = null
84 
85     @ColorInt private var dozingColor = 0
86     @ColorInt private var lockScreenColor = 0
87 
88     private var lineSpacingScale = 1f
89     private val chargeAnimationDelay: Int
90     private var textAnimator: TextAnimator? = null
91     private var onTextAnimatorInitialized: ((TextAnimator) -> Unit)? = null
92 
93     private var translateForCenterAnimation = false
94     private val parentWidth: Int
95         get() = (parent as View).measuredWidth
96 
97     // last text size which is not constrained by view height
98     private var lastUnconstrainedTextSize: Float = Float.MAX_VALUE
99 
100     @VisibleForTesting
101     var textAnimatorFactory: (Layout, () -> Unit) -> TextAnimator = { layout, invalidateCb ->
102         val cache = TypefaceVariantCacheImpl(layout.paint.typeface, NUM_CLOCK_FONT_ANIMATION_STEPS)
103         TextAnimator(layout, cache, invalidateCb)
104     }
105 
106     // Used by screenshot tests to provide stability
107     @VisibleForTesting var isAnimationEnabled: Boolean = true
108     @VisibleForTesting var timeOverrideInMillis: Long? = null
109 
110     val dozingWeight: Int
111         get() = if (useBoldedVersion()) dozingWeightInternal + 100 else dozingWeightInternal
112 
113     val lockScreenWeight: Int
114         get() = if (useBoldedVersion()) lockScreenWeightInternal + 100 else lockScreenWeightInternal
115 
116     /**
117      * The number of pixels below the baseline. For fonts that support languages such as Burmese,
118      * this space can be significant and should be accounted for when computing layout.
119      */
120     val bottom: Float
121         get() = paint?.fontMetrics?.bottom ?: 0f
122 
123     init {
124         val animatableClockViewAttributes =
125             context.obtainStyledAttributes(
126                 attrs,
127                 R.styleable.AnimatableClockView,
128                 defStyleAttr,
129                 defStyleRes,
130             )
131 
132         try {
133             dozingWeightInternal =
134                 animatableClockViewAttributes.getInt(
135                     R.styleable.AnimatableClockView_dozeWeight,
136                     /* default = */ 100,
137                 )
138             lockScreenWeightInternal =
139                 animatableClockViewAttributes.getInt(
140                     R.styleable.AnimatableClockView_lockScreenWeight,
141                     /* default = */ 300,
142                 )
143             chargeAnimationDelay =
144                 animatableClockViewAttributes.getInt(
145                     R.styleable.AnimatableClockView_chargeAnimationDelay,
146                     /* default = */ 200,
147                 )
148         } finally {
149             animatableClockViewAttributes.recycle()
150         }
151 
152         val textViewAttributes =
153             context.obtainStyledAttributes(
154                 attrs,
155                 android.R.styleable.TextView,
156                 defStyleAttr,
157                 defStyleRes,
158             )
159 
160         try {
161             isSingleLineInternal =
162                 textViewAttributes.getBoolean(
163                     android.R.styleable.TextView_singleLine,
164                     /* default = */ false,
165                 )
166         } finally {
167             textViewAttributes.recycle()
168         }
169 
170         refreshFormat()
171     }
172 
173     override fun onAttachedToWindow() {
174         logger.d("onAttachedToWindow")
175         super.onAttachedToWindow()
176         refreshFormat()
177     }
178 
179     /** Whether to use a bolded version based on the user specified fontWeightAdjustment. */
180     fun useBoldedVersion(): Boolean {
181         // "Bold text" fontWeightAdjustment is 300.
182         return resources.configuration.fontWeightAdjustment > 100
183     }
184 
185     fun refreshTime() {
186         time.timeInMillis = timeOverrideInMillis ?: System.currentTimeMillis()
187         contentDescription = DateFormat.format(descFormat, time)
188         val formattedText = DateFormat.format(format, time)
189         logger.d({ "refreshTime: new formattedText=$str1" }) { str1 = formattedText?.toString() }
190 
191         // Setting text actually triggers a layout pass in TextView (because the text view is set to
192         // wrap_content width and TextView always relayouts for this). This avoids needless relayout
193         // if the text didn't actually change.
194         if (TextUtils.equals(text, formattedText)) {
195             return
196         }
197 
198         text = formattedText
199         logger.d({ "refreshTime: done setting new time text to: $str1" }) {
200             str1 = formattedText?.toString()
201         }
202 
203         // Because the TextLayout may mutate under the hood as a result of the new text, we notify
204         // the TextAnimator that it may have changed and request a measure/layout. A crash will
205         // occur on the next invocation of setTextStyle if the layout is mutated without being
206         // notified TextInterpolator being notified.
207         if (layout != null) {
208             textAnimator?.updateLayout(layout)
209             logger.d("refreshTime: done updating textAnimator layout")
210         }
211 
212         requestLayout()
213         logger.d("refreshTime: after requestLayout")
214     }
215 
216     fun onTimeZoneChanged(timeZone: TimeZone?) {
217         logger.d({ "onTimeZoneChanged($str1)" }) { str1 = timeZone?.toString() }
218         time.timeZone = timeZone
219         refreshFormat()
220     }
221 
222     override fun setTextSize(type: Int, size: Float) {
223         super.setTextSize(type, size)
224         lastUnconstrainedTextSize = if (type == COMPLEX_UNIT_PX) size else Float.MAX_VALUE
225     }
226 
227     @SuppressLint("DrawAllocation")
228     override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
229         logger.d("onMeasure")
230 
231         if (
232             migratedClocks &&
233                 !isSingleLineInternal &&
234                 MeasureSpec.getMode(heightMeasureSpec) == EXACTLY
235         ) {
236             // Call straight into TextView.setTextSize to avoid setting lastUnconstrainedTextSize
237             val size = min(lastUnconstrainedTextSize, MeasureSpec.getSize(heightMeasureSpec) / 2F)
238             super.setTextSize(COMPLEX_UNIT_PX, size)
239         }
240 
241         super.onMeasure(widthMeasureSpec, heightMeasureSpec)
242         textAnimator?.let { animator -> animator.updateLayout(layout, textSize) }
243             ?: run {
244                 textAnimator =
245                     textAnimatorFactory(layout, ::invalidate).also {
246                         onTextAnimatorInitialized?.invoke(it)
247                         onTextAnimatorInitialized = null
248                     }
249             }
250 
251         if (migratedClocks && hasCustomPositionUpdatedAnimation) {
252             // Expand width to avoid clock being clipped during stepping animation
253             val targetWidth = measuredWidth + MeasureSpec.getSize(widthMeasureSpec) / 2
254 
255             // This comparison is effectively a check if we're in splitshade or not
256             translateForCenterAnimation = parentWidth > targetWidth
257             if (translateForCenterAnimation) {
258                 setMeasuredDimension(targetWidth, measuredHeight)
259             }
260         } else {
261             translateForCenterAnimation = false
262         }
263     }
264 
265     override fun onDraw(canvas: Canvas) {
266         canvas.save()
267         if (translateForCenterAnimation) {
268             canvas.translate(parentWidth / 4f, 0f)
269         }
270 
271         logger.d({ "onDraw($str1)" }) { str1 = text.toString() }
272         // intentionally doesn't call super.onDraw here or else the text will be rendered twice
273         textAnimator?.draw(canvas)
274         canvas.restore()
275     }
276 
277     override fun invalidate() {
278         logger.d("invalidate")
279         super.invalidate()
280     }
281 
282     override fun onTextChanged(
283         text: CharSequence,
284         start: Int,
285         lengthBefore: Int,
286         lengthAfter: Int,
287     ) {
288         logger.d({ "onTextChanged($str1)" }) { str1 = text.toString() }
289         super.onTextChanged(text, start, lengthBefore, lengthAfter)
290     }
291 
292     fun setLineSpacingScale(scale: Float) {
293         lineSpacingScale = scale
294         setLineSpacing(0f, lineSpacingScale)
295     }
296 
297     fun setColors(@ColorInt dozingColor: Int, lockScreenColor: Int) {
298         this.dozingColor = dozingColor
299         this.lockScreenColor = lockScreenColor
300     }
301 
302     fun animateColorChange() {
303         logger.d("animateColorChange")
304         setTextStyle(
305             weight = lockScreenWeight,
306             color = null, /* using current color */
307             animate = false,
308             interpolator = null,
309             duration = 0,
310             delay = 0,
311             onAnimationEnd = null,
312         )
313         setTextStyle(
314             weight = lockScreenWeight,
315             color = lockScreenColor,
316             animate = true,
317             interpolator = null,
318             duration = COLOR_ANIM_DURATION,
319             delay = 0,
320             onAnimationEnd = null,
321         )
322     }
323 
324     fun animateAppearOnLockscreen() {
325         logger.d("animateAppearOnLockscreen")
326         setTextStyle(
327             weight = dozingWeight,
328             color = lockScreenColor,
329             animate = false,
330             interpolator = null,
331             duration = 0,
332             delay = 0,
333             onAnimationEnd = null,
334         )
335         setTextStyle(
336             weight = lockScreenWeight,
337             color = lockScreenColor,
338             animate = true,
339             duration = APPEAR_ANIM_DURATION,
340             interpolator = Interpolators.EMPHASIZED_DECELERATE,
341             delay = 0,
342             onAnimationEnd = null,
343         )
344     }
345 
346     fun animateFoldAppear(animate: Boolean = true) {
347         if (textAnimator == null) {
348             return
349         }
350 
351         logger.d("animateFoldAppear")
352         setTextStyle(
353             weight = lockScreenWeightInternal,
354             color = lockScreenColor,
355             animate = false,
356             interpolator = null,
357             duration = 0,
358             delay = 0,
359             onAnimationEnd = null,
360         )
361         setTextStyle(
362             weight = dozingWeightInternal,
363             color = dozingColor,
364             animate = animate,
365             interpolator = Interpolators.EMPHASIZED_DECELERATE,
366             duration = ANIMATION_DURATION_FOLD_TO_AOD.toLong(),
367             delay = 0,
368             onAnimationEnd = null,
369         )
370     }
371 
372     fun animateCharge(isDozing: () -> Boolean) {
373         // Skip charge animation if dozing animation is already playing.
374         if (textAnimator == null || textAnimator!!.isRunning()) {
375             return
376         }
377 
378         logger.d("animateCharge")
379         val startAnimPhase2 = Runnable {
380             setTextStyle(
381                 weight = if (isDozing()) dozingWeight else lockScreenWeight,
382                 color = null,
383                 animate = true,
384                 interpolator = null,
385                 duration = CHARGE_ANIM_DURATION_PHASE_1,
386                 delay = 0,
387                 onAnimationEnd = null,
388             )
389         }
390         setTextStyle(
391             weight = if (isDozing()) lockScreenWeight else dozingWeight,
392             color = null,
393             animate = true,
394             interpolator = null,
395             duration = CHARGE_ANIM_DURATION_PHASE_0,
396             delay = chargeAnimationDelay.toLong(),
397             onAnimationEnd = startAnimPhase2,
398         )
399     }
400 
401     fun animateDoze(isDozing: Boolean, animate: Boolean) {
402         logger.d("animateDoze")
403         setTextStyle(
404             weight = if (isDozing) dozingWeight else lockScreenWeight,
405             color = if (isDozing) dozingColor else lockScreenColor,
406             animate = animate,
407             interpolator = null,
408             duration = DOZE_ANIM_DURATION,
409             delay = 0,
410             onAnimationEnd = null,
411         )
412     }
413 
414     // The offset of each glyph from where it should be.
415     private var glyphOffsets = mutableListOf(0.0f, 0.0f, 0.0f, 0.0f)
416 
417     private var lastSeenAnimationProgress = 1.0f
418 
419     // If the animation is being reversed, the target offset for each glyph for the "stop".
420     private var animationCancelStartPosition = mutableListOf(0.0f, 0.0f, 0.0f, 0.0f)
421     private var animationCancelStopPosition = 0.0f
422 
423     // Whether the currently playing animation needed a stop (and thus, is shortened).
424     private var currentAnimationNeededStop = false
425 
426     private val glyphFilter: GlyphCallback = { positionedGlyph, _ ->
427         val offset = positionedGlyph.lineNo * DIGITS_PER_LINE + positionedGlyph.glyphIndex
428         if (offset < glyphOffsets.size) {
429             positionedGlyph.x += glyphOffsets[offset]
430         }
431     }
432 
433     /**
434      * Set text style with an optional animation.
435      * - By passing -1 to weight, the view preserves its current weight.
436      * - By passing -1 to textSize, the view preserves its current text size.
437      * - By passing null to color, the view preserves its current color.
438      *
439      * @param weight text weight.
440      * @param textSize font size.
441      * @param animate true to animate the text style change, otherwise false.
442      */
443     private fun setTextStyle(
444         @IntRange(from = 0, to = 1000) weight: Int,
445         color: Int?,
446         animate: Boolean,
447         interpolator: TimeInterpolator?,
448         duration: Long,
449         delay: Long,
450         onAnimationEnd: Runnable?,
451     ) {
452         textAnimator?.let {
453             it.setTextStyle(
454                 weight = weight,
455                 color = color,
456                 animate = animate && isAnimationEnabled,
457                 duration = duration,
458                 interpolator = interpolator,
459                 delay = delay,
460                 onAnimationEnd = onAnimationEnd,
461             )
462             it.glyphFilter = glyphFilter
463         }
464             ?: run {
465                 // when the text animator is set, update its start values
466                 onTextAnimatorInitialized = { textAnimator ->
467                     textAnimator.setTextStyle(
468                         weight = weight,
469                         color = color,
470                         animate = false,
471                         duration = duration,
472                         interpolator = interpolator,
473                         delay = delay,
474                         onAnimationEnd = onAnimationEnd,
475                     )
476                     textAnimator.glyphFilter = glyphFilter
477                 }
478             }
479     }
480 
481     fun refreshFormat() = refreshFormat(DateFormat.is24HourFormat(context))
482 
483     fun refreshFormat(use24HourFormat: Boolean) {
484         Patterns.update(context)
485 
486         format =
487             when {
488                 isSingleLineInternal && use24HourFormat -> Patterns.sClockView24
489                 !isSingleLineInternal && use24HourFormat -> DOUBLE_LINE_FORMAT_24_HOUR
490                 isSingleLineInternal && !use24HourFormat -> Patterns.sClockView12
491                 else -> DOUBLE_LINE_FORMAT_12_HOUR
492             }
493         logger.d({ "refreshFormat($str1)" }) { str1 = format?.toString() }
494 
495         descFormat = if (use24HourFormat) Patterns.sClockView24 else Patterns.sClockView12
496         refreshTime()
497     }
498 
499     fun dump(pw: PrintWriter) {
500         pw.println("$this")
501         pw.println("    alpha=$alpha")
502         pw.println("    measuredWidth=$measuredWidth")
503         pw.println("    measuredHeight=$measuredHeight")
504         pw.println("    singleLineInternal=$isSingleLineInternal")
505         pw.println("    currText=$text")
506         pw.println("    currTimeContextDesc=$contentDescription")
507         pw.println("    dozingWeightInternal=$dozingWeightInternal")
508         pw.println("    lockScreenWeightInternal=$lockScreenWeightInternal")
509         pw.println("    dozingColor=$dozingColor")
510         pw.println("    lockScreenColor=$lockScreenColor")
511         pw.println("    time=$time")
512     }
513 
514     private val moveToCenterDelays: List<Int>
515         get() = if (isLayoutRtl) MOVE_LEFT_DELAYS else MOVE_RIGHT_DELAYS
516 
517     private val moveToSideDelays: List<Int>
518         get() = if (isLayoutRtl) MOVE_RIGHT_DELAYS else MOVE_LEFT_DELAYS
519 
520     /**
521      * Offsets the glyphs of the clock for the step clock animation.
522      *
523      * The animation makes the glyphs of the clock move at different speeds, when the clock is
524      * moving horizontally.
525      *
526      * @param clockStartLeft the [getLeft] position of the clock, before it started moving.
527      * @param clockMoveDirection the direction in which it is moving. A positive number means right,
528      *   and negative means left.
529      * @param moveFraction fraction of the clock movement. 0 means it is at the beginning, and 1
530      *   means it finished moving.
531      */
532     fun offsetGlyphsForStepClockAnimation(
533         clockStartLeft: Int,
534         clockMoveDirection: Int,
535         moveFraction: Float,
536     ) {
537         val isMovingToCenter = if (isLayoutRtl) clockMoveDirection < 0 else clockMoveDirection > 0
538         // The sign of moveAmountDeltaForDigit is already set here
539         // we can interpret (left - clockStartLeft) as (destinationPosition - originPosition)
540         // so we no longer need to multiply direct sign to moveAmountDeltaForDigit
541         val currentMoveAmount = left - clockStartLeft
542         for (i in 0 until NUM_DIGITS) {
543             val digitFraction =
544                 getDigitFraction(
545                     digit = i,
546                     isMovingToCenter = isMovingToCenter,
547                     fraction = moveFraction,
548                 )
549             val moveAmountForDigit = currentMoveAmount * digitFraction
550             val moveAmountDeltaForDigit = moveAmountForDigit - currentMoveAmount
551             glyphOffsets[i] = moveAmountDeltaForDigit
552         }
553         invalidate()
554     }
555 
556     /**
557      * Offsets the glyphs of the clock for the step clock animation.
558      *
559      * The animation makes the glyphs of the clock move at different speeds, when the clock is
560      * moving horizontally. This method uses direction, distance, and fraction to determine offset.
561      *
562      * @param distance is the total distance in pixels to offset the glyphs when animation
563      *   completes. Negative distance means we are animating the position towards the center.
564      * @param fraction fraction of the clock movement. 0 means it is at the beginning, and 1 means
565      *   it finished moving.
566      */
567     fun offsetGlyphsForStepClockAnimation(distance: Float, fraction: Float) {
568         for (i in 0 until NUM_DIGITS) {
569             val dir = if (isLayoutRtl) -1 else 1
570             val digitFraction =
571                 getDigitFraction(digit = i, isMovingToCenter = distance > 0, fraction = fraction)
572             val moveAmountForDigit = dir * distance * digitFraction
573             glyphOffsets[i] = moveAmountForDigit
574 
575             if (distance > 0) {
576                 // If distance > 0 then we are moving from the left towards the center. We need to
577                 // ensure that the glyphs are offset to the initial position.
578                 glyphOffsets[i] -= dir * distance
579             }
580         }
581         invalidate()
582     }
583 
584     override fun onRtlPropertiesChanged(layoutDirection: Int) {
585         if (migratedClocks) {
586             if (layoutDirection == LAYOUT_DIRECTION_RTL) {
587                 textAlignment = TEXT_ALIGNMENT_TEXT_END
588             } else {
589                 textAlignment = TEXT_ALIGNMENT_TEXT_START
590             }
591         }
592         super.onRtlPropertiesChanged(layoutDirection)
593     }
594 
595     private fun getDigitFraction(digit: Int, isMovingToCenter: Boolean, fraction: Float): Float {
596         // The delay for the digit, in terms of fraction.
597         // (i.e. the digit should not move during 0.0 - 0.1).
598         val delays = if (isMovingToCenter) moveToCenterDelays else moveToSideDelays
599         val digitInitialDelay = delays[digit] * MOVE_DIGIT_STEP
600         return MOVE_INTERPOLATOR.getInterpolation(
601             constrainedMap(
602                 /* rangeMin= */ 0.0f,
603                 /* rangeMax= */ 1.0f,
604                 /* valueMin= */ digitInitialDelay,
605                 /* valueMax= */ digitInitialDelay + AVAILABLE_ANIMATION_TIME,
606                 /* value= */ fraction,
607             )
608         )
609     }
610 
611     /**
612      * DateFormat.getBestDateTimePattern is extremely expensive, and refresh is called often. This
613      * is a cache optimization to ensure we only recompute the patterns when the inputs change.
614      */
615     private object Patterns {
616         var sClockView12: String? = null
617         var sClockView24: String? = null
618         var sCacheKey: String? = null
619 
620         fun update(context: Context) {
621             val locale = Locale.getDefault()
622             val clockView12Skel = context.resources.getString(R.string.clock_12hr_format)
623             val clockView24Skel = context.resources.getString(R.string.clock_24hr_format)
624             val key = "$locale$clockView12Skel$clockView24Skel"
625             if (key == sCacheKey) {
626                 return
627             }
628 
629             sClockView12 =
630                 DateFormat.getBestDateTimePattern(locale, clockView12Skel).let {
631                     // CLDR insists on adding an AM/PM indicator even though it wasn't in the format
632                     // string. The following code removes the AM/PM indicator if we didn't want it.
633                     if (!clockView12Skel.contains("a")) {
634                         it.replace("a".toRegex(), "").trim { it <= ' ' }
635                     } else it
636                 }
637 
638             sClockView24 = DateFormat.getBestDateTimePattern(locale, clockView24Skel)
639             sCacheKey = key
640         }
641     }
642 
643     companion object {
644         private val TAG = AnimatableClockView::class.simpleName!!
645         private val DEFAULT_LOGGER = Logger(LogcatOnlyMessageBuffer(LogLevel.WARNING), TAG)
646 
647         const val ANIMATION_DURATION_FOLD_TO_AOD: Int = 600
648         private const val DOUBLE_LINE_FORMAT_12_HOUR = "hh\nmm"
649         private const val DOUBLE_LINE_FORMAT_24_HOUR = "HH\nmm"
650         private const val DOZE_ANIM_DURATION: Long = 300
651         private const val APPEAR_ANIM_DURATION: Long = 833
652         private const val CHARGE_ANIM_DURATION_PHASE_0: Long = 500
653         private const val CHARGE_ANIM_DURATION_PHASE_1: Long = 1000
654         private const val COLOR_ANIM_DURATION: Long = 400
655         private const val NUM_CLOCK_FONT_ANIMATION_STEPS = 30
656 
657         // Constants for the animation
658         private val MOVE_INTERPOLATOR = Interpolators.EMPHASIZED
659 
660         // Calculate the positions of all of the digits...
661         // Offset each digit by, say, 0.1
662         // This means that each digit needs to move over a slice of "fractions", i.e. digit 0 should
663         // move from 0.0 - 0.7, digit 1 from 0.1 - 0.8, digit 2 from 0.2 - 0.9, and digit 3
664         // from 0.3 - 1.0.
665         private const val NUM_DIGITS = 4
666         private const val DIGITS_PER_LINE = 2
667 
668         // Delays. Each digit's animation should have a slight delay, so we get a nice
669         // "stepping" effect. When moving right, the second digit of the hour should move first.
670         // When moving left, the first digit of the hour should move first. The lists encode
671         // the delay for each digit (hour[0], hour[1], minute[0], minute[1]), to be multiplied
672         // by delayMultiplier.
673         private val MOVE_LEFT_DELAYS = listOf(0, 1, 2, 3)
674         private val MOVE_RIGHT_DELAYS = listOf(1, 0, 3, 2)
675 
676         // How much delay to apply to each subsequent digit. This is measured in terms of "fraction"
677         // (i.e. a value of 0.1 would cause a digit to wait until fraction had hit 0.1, or 0.2 etc
678         // before moving).
679         //
680         // The current specs dictate that each digit should have a 33ms gap between them. The
681         // overall time is 1s right now.
682         private const val MOVE_DIGIT_STEP = 0.033f
683 
684         // Total available transition time for each digit, taking into account the step. If step is
685         // 0.1, then digit 0 would animate over 0.0 - 0.7, making availableTime 0.7.
686         private const val AVAILABLE_ANIMATION_TIME = 1.0f - MOVE_DIGIT_STEP * (NUM_DIGITS - 1)
687     }
688 }
689