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