A fork of Simple Time Tracker with WearOS support. (Now integrated upstream)
0

Configure Feed

Select the types of activity you want to include in your feed.

add pie chart particles animation

razeeman (May 6, 2023, 8:34 AM +0300) 6490127f 7a35f733

+175 -41
+1
features/feature_statistics/src/main/res/layout/item_statistics_chart_layout.xml
··· 56 56 app:dividerColor="?appPieChartDividerColor" 57 57 app:dividerWidth="2dp" 58 58 app:drawIcons="true" 59 + app:drawParticles="true" 59 60 app:iconMaxSize="24dp" 60 61 app:iconPadding="4dp" 61 62 app:innerRadiusRatio="0.625"
+1 -1
features/feature_views/src/main/res/values/attrs.xml
··· 132 132 <attr name="iconMaxSize" format="dimension" /> 133 133 <attr name="innerRadiusRatio" format="float" /> 134 134 <attr name="drawIcons" format="boolean" /> 135 - <attr name="pieChartAnimated" format="boolean" /> 135 + <attr name="drawParticles" format="boolean" /> 136 136 </declare-styleable> 137 137 </resources>
+1 -1
features/feature_widget/src/main/res/layout/widget_statistics_chart_view_layout.xml
··· 24 24 app:dividerColor="@color/grey_950" 25 25 app:dividerWidth="2dp" 26 26 app:drawIcons="true" 27 + app:drawParticles="false" 27 28 app:iconMaxSize="48dp" 28 29 app:iconPadding="4dp" 29 30 app:innerRadiusRatio="0.625" ··· 32 33 app:layout_constraintStart_toStartOf="parent" 33 34 app:layout_constraintTop_toTopOf="parent" 34 35 app:layout_constraintWidth_percent="0.975" 35 - app:pieChartAnimated="false" 36 36 tools:segmentCount="5" /> 37 37 38 38 <androidx.appcompat.widget.AppCompatTextView
+5 -2
features/feature_statistics/src/main/java/com/example/util/simpletimetracker/feature_statistics/adapter/StatisticsChartAdapterDelegate.kt
··· 16 16 with(binding) { 17 17 item as ViewData 18 18 19 - chartStatisticsItem.setAnimated(item.animated) 20 - chartStatisticsItem.setSegments(item.data) 19 + chartStatisticsItem.setSegments( 20 + data = item.data, 21 + animateOpen = item.animatedOpen, 22 + animateParticles = item.animateParticles 23 + ) 21 24 22 25 btnStatisticsChartFilter.isVisible = item.buttonsVisible 23 26 btnStatisticsChartShare.isVisible = item.buttonsVisible
+3 -1
features/feature_statistics/src/main/java/com/example/util/simpletimetracker/feature_statistics/interactor/StatisticsViewDataInteractor.kt
··· 69 69 rangeLength: RangeLength, 70 70 shift: Int, 71 71 forSharing: Boolean, 72 + isVisible: Boolean, 72 73 ): List<ViewHolderType> { 73 74 val filterType = prefsInteractor.getChartFilterType() 74 75 val isDarkTheme = prefsInteractor.getDarkMode() ··· 128 129 129 130 StatisticsChartViewData( 130 131 data = data, 131 - animated = !forSharing, 132 + animatedOpen = !forSharing, 133 + animateParticles = isVisible, 132 134 buttonsVisible = !forSharing, 133 135 ) 134 136 }
+1 -1
features/feature_statistics/src/main/java/com/example/util/simpletimetracker/feature_statistics/view/StatisticsFragment.kt
··· 1 1 package com.example.util.simpletimetracker.feature_statistics.view 2 2 3 - import com.example.util.simpletimetracker.feature_statistics.databinding.StatisticsFragmentBinding as Binding 4 3 import android.os.Bundle 5 4 import android.view.LayoutInflater 6 5 import android.view.ViewGroup ··· 33 32 import com.example.util.simpletimetracker.navigation.params.screen.StatisticsParams 34 33 import dagger.hilt.android.AndroidEntryPoint 35 34 import javax.inject.Inject 35 + import com.example.util.simpletimetracker.feature_statistics.databinding.StatisticsFragmentBinding as Binding 36 36 37 37 @AndroidEntryPoint 38 38 class StatisticsFragment :
+2 -1
features/feature_statistics/src/main/java/com/example/util/simpletimetracker/feature_statistics/viewData/StatisticsChartViewData.kt
··· 5 5 6 6 data class StatisticsChartViewData( 7 7 val data: List<PiePortion>, 8 - val animated: Boolean, 8 + val animatedOpen: Boolean, 9 + val animateParticles: Boolean, 9 10 val buttonsVisible: Boolean, 10 11 ) : ViewHolderType { 11 12
+2
features/feature_statistics/src/main/java/com/example/util/simpletimetracker/feature_statistics/viewModel/StatisticsViewModel.kt
··· 63 63 fun onHidden() { 64 64 isVisible = false 65 65 stopUpdate() 66 + updateStatistics() 66 67 } 67 68 68 69 fun onRangeUpdated() { ··· 146 147 rangeLength = prefsInteractor.getStatisticsRange(), 147 148 shift = shift, 148 149 forSharing = forSharing, 150 + isVisible = isVisible, 149 151 ) 150 152 } 151 153
+154 -33
features/feature_views/src/main/java/com/example/util/simpletimetracker/feature_views/pieChart/PieChartView.kt
··· 2 2 3 3 import android.animation.ValueAnimator 4 4 import android.content.Context 5 + import android.graphics.Bitmap 5 6 import android.graphics.BlurMaskFilter 6 7 import android.graphics.Canvas 7 8 import android.graphics.Color 8 9 import android.graphics.Paint 10 + import android.graphics.PorterDuff 11 + import android.graphics.PorterDuffXfermode 9 12 import android.graphics.Rect 10 13 import android.graphics.RectF 11 - import android.graphics.drawable.BitmapDrawable 12 - import android.graphics.drawable.Drawable 13 14 import android.util.AttributeSet 14 15 import android.view.ContextThemeWrapper 15 16 import android.view.View 17 + import androidx.annotation.FloatRange 18 + import com.example.util.simpletimetracker.feature_views.IconView 19 + import com.example.util.simpletimetracker.feature_views.R 16 20 import com.example.util.simpletimetracker.feature_views.extension.getBitmapFromView 17 21 import com.example.util.simpletimetracker.feature_views.extension.measureExactly 18 - import com.example.util.simpletimetracker.feature_views.IconView 19 22 import com.example.util.simpletimetracker.feature_views.viewData.RecordTypeIcon 20 - import com.example.util.simpletimetracker.feature_views.R 23 + import kotlin.math.ceil 24 + import kotlin.math.cos 25 + import kotlin.math.floor 21 26 import kotlin.math.max 22 27 import kotlin.math.min 28 + import kotlin.math.sin 23 29 24 30 class PieChartView @JvmOverloads constructor( 25 31 context: Context, ··· 39 45 private var iconMaxSize: Int = 0 40 46 private var segmentCount: Int = 0 41 47 private var drawIcons: Boolean = false 42 - private var animated: Boolean = true 48 + private var drawParticles: Boolean = false 43 49 // End of attrs 44 50 45 51 private val segmentPaint: Paint = Paint() 46 52 private val shadowPaint: Paint = Paint() 47 53 private val dividerPaint: Paint = Paint() 54 + private val particlePaint: Paint = Paint() 48 55 49 56 private val animator = ValueAnimator.ofFloat(0f, 1f) 50 57 private val bounds: RectF = RectF(0f, 0f, 0f, 0f) 58 + private val layerBounds: RectF = RectF(0f, 0f, 0f, 0f) 59 + private val particleBounds: RectF = RectF(0f, 0f, 0f, 0f) 51 60 private var segments: List<Arc> = emptyList() 52 61 private var shadowColor: Int = 0x40000000 53 62 private var segmentAnimationScale: Float = 1f 54 - private val segmentAnimationDuration: Long = 200L // ms 55 - private var shouldAnimate: Boolean = true 63 + private var shouldAnimateOpen: Boolean = true 64 + private var animateParticles: Boolean = false 56 65 private val iconView: IconView = IconView(ContextThemeWrapper(context, R.style.AppTheme)) 57 66 58 67 init { ··· 82 91 drawIcons(canvas, w, h, r) 83 92 } 84 93 85 - // Set before setting data. 86 - fun setAnimated(animated: Boolean) { 87 - this.animated = animated 88 - } 89 - 90 - fun setSegments(data: List<PiePortion>) { 94 + fun setSegments( 95 + data: List<PiePortion>, 96 + animateOpen: Boolean, 97 + animateParticles: Boolean, 98 + ) { 91 99 val res = mutableListOf<Arc>() 92 100 val valuesSum = data.map(PiePortion::value).sum() 93 101 var segmentPercent: Float 94 - var drawable: Drawable? = null 102 + var drawable: Bitmap? = null 95 103 96 104 data.forEach { segment -> 97 105 if (drawIcons && segment.iconId != null) { ··· 112 120 } 113 121 114 122 segments = res 123 + this.animateParticles = animateParticles 115 124 invalidate() 116 - if (!isInEditMode) animateSegments() 125 + if (!isInEditMode && animateOpen) animateSegmentsAppearing() 117 126 } 118 127 119 128 private fun initArgs( ··· 141 150 getInt(R.styleable.PieChartView_segmentCount, 0) 142 151 drawIcons = 143 152 getBoolean(R.styleable.PieChartView_drawIcons, false) 144 - 145 - if (hasValue(R.styleable.PieChartView_pieChartAnimated)) animated = 146 - getBoolean(R.styleable.PieChartView_pieChartAnimated, true) 153 + drawParticles = 154 + getBoolean(R.styleable.PieChartView_drawParticles, false) 147 155 recycle() 148 156 } 149 157 } ··· 165 173 strokeWidth = dividerWidth.toFloat() 166 174 style = Paint.Style.STROKE 167 175 } 176 + particlePaint.apply { 177 + isAntiAlias = true 178 + xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP) 179 + } 168 180 } 169 181 170 182 private fun drawShadow(canvas: Canvas, w: Float, h: Float, r: Float) { ··· 185 197 ) 186 198 } 187 199 200 + @Suppress("DEPRECATION") 188 201 private fun drawSegments(canvas: Canvas, w: Float, h: Float, r: Float) { 189 202 val segmentWidth = r - r * innerRadiusRatio 190 203 val segmentCenterLine = r - segmentWidth / 2 191 - var currentSweepAngle = 0f 204 + var currentSweepAngle = -90f 192 205 var sweepAngle: Float 193 206 segmentPaint.strokeWidth = segmentWidth 194 - 195 - canvas.save() 196 - canvas.rotate(-90f, w / 2, h / 2) 197 207 198 208 bounds.set( 199 209 w / 2 - segmentCenterLine, h / 2 - segmentCenterLine, 200 210 w / 2 + segmentCenterLine, h / 2 + segmentCenterLine 201 211 ) 212 + layerBounds.set( 213 + w / 2 - r, h / 2 - r, 214 + w / 2 + r, h / 2 + r 215 + ) 202 216 segments.forEach { 217 + canvas.saveLayerAlpha(layerBounds, 0xFF, Canvas.ALL_SAVE_FLAG) 218 + 203 219 sweepAngle = it.arcPercent * 360f * segmentAnimationScale 204 220 segmentPaint.color = it.color 205 221 canvas.drawArc( ··· 209 225 false, 210 226 segmentPaint 211 227 ) 228 + drawParticles( 229 + segment = it, 230 + canvas = canvas, 231 + currentSweepAngle = currentSweepAngle, 232 + sweepAngle = sweepAngle, 233 + w = w, 234 + h = h, 235 + r = r, 236 + ) 212 237 currentSweepAngle += sweepAngle 238 + 239 + canvas.restore() 240 + } 241 + } 242 + 243 + private fun drawParticles( 244 + segment: PieChartView.Arc, 245 + canvas: Canvas, 246 + currentSweepAngle: Float, 247 + sweepAngle: Float, 248 + w: Float, 249 + h: Float, 250 + r: Float, 251 + ) { 252 + if (!drawParticles) return 253 + if (segment.drawable == null) return 254 + if (sweepAngle < PARTICLES_ANGLE_CUTOFF) return 255 + 256 + val now = System.currentTimeMillis() 257 + if (initializationTime == -1L) { 258 + initializationTime = now 259 + } 260 + val time = (now - initializationTime) / PARTICLES_CYCLE 261 + 262 + val iconSizeHalfSize = calculateIconSize(r) / 2f 263 + particleBounds.set( 264 + -iconSizeHalfSize, -iconSizeHalfSize, 265 + iconSizeHalfSize, iconSizeHalfSize 266 + ) 267 + 268 + val fromAngle = floor(currentSweepAngle / PARTICLES_ANGLE_STEP).toInt() 269 + val toAngle = ceil((currentSweepAngle + sweepAngle) / PARTICLES_ANGLE_STEP).toInt() 270 + 271 + for (i in fromAngle..toAngle) { 272 + val angle = i * PARTICLES_ANGLE_STEP 273 + 274 + val startTimeVariation = PARTICLES_CYCLE * 100 * pseudoRandom(angle) 275 + val speedVariation = PARTICLES_BASE_SPEED + PARTICLES_SPEED_VARIATION * pseudoRandom(angle) 276 + val distanceVariation = ((time + startTimeVariation) * speedVariation) % 1.0 277 + 278 + val innerParticleSpanDistance = r * innerRadiusRatio - iconSizeHalfSize 279 + val outerParticleSpanDistance = r + iconSizeHalfSize 280 + val particleDistance = interpolate( 281 + 0f, outerParticleSpanDistance, distanceVariation 282 + ) 283 + 284 + if ( 285 + particleDistance > innerParticleSpanDistance && 286 + particleDistance < outerParticleSpanDistance 287 + ) { 288 + val alpha = PARTICLES_BASE_ALPHA + PARTICLES_ALPHA_VARIATION * pseudoRandom(angle + 1) 289 + val scale = PARTICLES_BASE_SCALE + PARTICLES_SCALE_VARIATION * pseudoRandom(angle + 2) 290 + val x = (w / 2) + particleDistance * cos(Math.toRadians(angle)) 291 + val y = (h / 2) + particleDistance * sin(Math.toRadians(angle)) 292 + particlePaint.alpha = (0xFF * alpha).toInt() 293 + 294 + canvas.save() 295 + canvas.translate(x.toFloat(), y.toFloat()) 296 + canvas.scale(scale.toFloat(), scale.toFloat()) 297 + canvas.drawBitmap(segment.drawable, null, particleBounds, particlePaint) 298 + canvas.restore() 299 + } 213 300 } 214 301 215 - canvas.restore() 302 + if (animateParticles) { 303 + invalidate() 304 + } 216 305 } 217 306 218 307 private fun drawDividers(canvas: Canvas, w: Float, h: Float, r: Float) { ··· 254 343 var center = canvas.save() 255 344 256 345 segments.forEach { 346 + it.drawable ?: return@forEach 347 + 257 348 // circleCircumference = 2 * Math.PI * r 258 349 // segmentRatio = it.sweepAngle / 360f 259 350 // segmentLength = circleCircumference * segmentRatio ··· 265 356 canvas.rotate(rotation) 266 357 canvas.translate(0f, -iconPositionFromCenter) 267 358 canvas.rotate(-rotation) 268 - it.drawable?.bounds = bounds 269 - it.drawable?.draw(canvas) 359 + canvas.drawBitmap(it.drawable, null, bounds, null) 270 360 271 361 currentSweepAngle += sweepAngle 272 362 canvas.restoreToCount(center) ··· 296 386 colorInt = Color.BLACK, 297 387 iconId = RecordTypeIcon.Image(R.drawable.unknown) 298 388 ) 299 - }.let(::setSegments) 389 + }.let { 390 + setSegments( 391 + data = it, 392 + animateOpen = false, 393 + animateParticles = false 394 + ) 395 + } 300 396 } 301 397 } 302 398 303 - private fun getIconDrawable(iconId: RecordTypeIcon): Drawable { 399 + private fun getIconDrawable(iconId: RecordTypeIcon): Bitmap { 304 400 return iconView 305 401 .apply { 306 402 itemIcon = iconId 307 403 measureExactly(iconMaxSize) 308 404 } 309 405 .getBitmapFromView() 310 - .let { BitmapDrawable(resources, it) } 311 406 } 312 407 313 - private fun animateSegments() { 314 - if (animated && shouldAnimate) { 315 - animator.duration = segmentAnimationDuration 408 + private fun animateSegmentsAppearing() { 409 + if (shouldAnimateOpen) { 410 + animator.duration = SEGMENT_OPEN_ANIMATION_DURATION_MS 316 411 animator.addUpdateListener { animation -> 317 412 segmentAnimationScale = animation.animatedValue as Float 318 413 invalidate() 319 414 } 320 415 animator.start() 321 - shouldAnimate = false 416 + shouldAnimateOpen = false 322 417 } 418 + } 419 + 420 + @FloatRange(from = 0.0, to = 1.0) 421 + private fun pseudoRandom(seed: Double): Double { 422 + return sin(seed) / 2.0 + 0.5 423 + } 424 + 425 + @Suppress("SameParameterValue") 426 + private fun interpolate(a: Float, b: Float, f: Double): Double { 427 + return a + f * (b - a) 323 428 } 324 429 325 430 private inner class Arc( 326 431 val color: Int, 327 - val drawable: Drawable? = null, 432 + val drawable: Bitmap? = null, 328 433 val arcPercent: Float 329 434 ) 435 + 436 + companion object { 437 + private var initializationTime: Long = -1 438 + 439 + private const val SEGMENT_OPEN_ANIMATION_DURATION_MS: Long = 250L 440 + 441 + private const val PARTICLES_ANGLE_STEP = 5.0 442 + private const val PARTICLES_ANGLE_CUTOFF = 7.0 443 + private const val PARTICLES_CYCLE = 30000.0 444 + private const val PARTICLES_BASE_SPEED = 1.0 445 + private const val PARTICLES_SPEED_VARIATION = 1.0 446 + private const val PARTICLES_BASE_ALPHA = 0.35 447 + private const val PARTICLES_ALPHA_VARIATION = -0.25 448 + private const val PARTICLES_BASE_SCALE = 0.7 449 + private const val PARTICLES_SCALE_VARIATION = -0.25 450 + } 330 451 }
+5 -1
features/feature_widget/src/main/java/com/example/util/simpletimetracker/feature_widget/statistics/customView/WidgetStatisticsChartView.kt
··· 36 36 switchDataVisibility(hasData = false) 37 37 } else { 38 38 switchDataVisibility(hasData = true) 39 - chartWidgetStatistics.setSegments(data) 39 + chartWidgetStatistics.setSegments( 40 + data = data, 41 + animateOpen = false, 42 + animateParticles = false 43 + ) 40 44 tvWidgetStatisticsTotal.text = total 41 45 } 42 46 invalidate()