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 streaks calendar to detailed statistics

razeeman (Jun 25, 2023, 7:35 PM +0300) a483c5fa e37cbb73

+262 -39
+10 -1
features/feature_statistics_detail/src/main/res/layout/statistics_detail_fragment.xml
··· 264 264 android:layout_marginTop="4dp" 265 265 app:layout_constraintTop_toBottomOf="@id/chartStatisticsComparisonStreaks" /> 266 266 267 + <com.example.util.simpletimetracker.feature_statistics_detail.customView.SeriesCalendarView 268 + android:id="@+id/chartStatisticsStreaksCalendar" 269 + android:layout_width="match_parent" 270 + android:layout_height="wrap_content" 271 + android:layout_marginStart="8dp" 272 + android:layout_marginTop="8dp" 273 + android:layout_marginEnd="8dp" 274 + app:layout_constraintTop_toBottomOf="@id/buttonsStatisticsDetailStreaksType" /> 275 + 267 276 <androidx.appcompat.widget.AppCompatTextView 268 277 android:id="@+id/tvStatisticsDetailSplitHint" 269 278 android:layout_width="match_parent" ··· 274 283 android:paddingTop="8dp" 275 284 android:text="@string/statistics_detail_day_split_hint" 276 285 android:textColor="?appTextHintColor" 277 - app:layout_constraintTop_toBottomOf="@id/buttonsStatisticsDetailStreaksType" /> 286 + app:layout_constraintTop_toBottomOf="@id/chartStatisticsStreaksCalendar" /> 278 287 279 288 <com.example.util.simpletimetracker.feature_statistics_detail.customView.BarChartView 280 289 android:id="@+id/chartStatisticsDetailSplit"
+121
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/customView/SeriesCalendarView.kt
··· 1 + package com.example.util.simpletimetracker.feature_statistics_detail.customView 2 + 3 + import android.content.Context 4 + import android.graphics.Canvas 5 + import android.graphics.Color 6 + import android.graphics.Paint 7 + import android.util.AttributeSet 8 + import android.view.View 9 + import androidx.annotation.ColorInt 10 + import com.example.util.simpletimetracker.feature_views.extension.dpToPx 11 + import kotlin.math.ceil 12 + import kotlin.math.roundToInt 13 + 14 + class SeriesCalendarView @JvmOverloads constructor( 15 + context: Context, 16 + attrs: AttributeSet? = null, 17 + defStyleAttr: Int = 0, 18 + ) : View( 19 + context, 20 + attrs, 21 + defStyleAttr 22 + ) { 23 + 24 + private val columnsCount: Int = 26 25 + private val rowsCount: Int = 7 26 + private val cellPadding: Float = 0.5f.dpToPx().toFloat() 27 + private val cellRadius: Float = 4f.dpToPx().toFloat() 28 + private var cellSize: Float = 0f 29 + private var cellColor: Int = Color.BLACK 30 + private var data: List<ViewData> = emptyList() 31 + 32 + private val cellPresentPaint: Paint = Paint() 33 + private val cellNotPresentPaint: Paint = Paint() 34 + 35 + init { 36 + initPaint() 37 + } 38 + 39 + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 40 + val w = resolveSize(0, widthMeasureSpec) 41 + cellSize = w / columnsCount.toFloat() 42 + val height: Float = cellSize * rowsCount 43 + val h = resolveSize(height.roundToInt(), heightMeasureSpec) 44 + 45 + setMeasuredDimension(w, h) 46 + } 47 + 48 + override fun onDraw(canvas: Canvas?) { 49 + if (canvas == null) return 50 + 51 + drawCells(canvas, width.toFloat(), height.toFloat()) 52 + } 53 + 54 + fun setData(data: List<ViewData>) { 55 + this.data = data 56 + invalidate() 57 + } 58 + 59 + fun setCellColor(@ColorInt color: Int) { 60 + cellColor = color 61 + initPaint() 62 + invalidate() 63 + } 64 + 65 + private fun initPaint() { 66 + cellPresentPaint.apply { 67 + isAntiAlias = true 68 + color = cellColor 69 + style = Paint.Style.FILL 70 + } 71 + cellNotPresentPaint.apply { 72 + isAntiAlias = true 73 + color = cellColor 74 + style = Paint.Style.FILL 75 + alpha = (255 * 0.3f).toInt() 76 + } 77 + } 78 + 79 + private fun drawCells(canvas: Canvas, w: Float, h: Float) { 80 + val dataColumnCount = ceil(data.size.toFloat() / rowsCount).toInt() 81 + val finalWidth = if (dataColumnCount < columnsCount) { 82 + (w + dataColumnCount * cellSize) / 2 83 + } else { 84 + w 85 + } 86 + 87 + // Draw from bottom right corner so that current day would be in there. 88 + canvas.save() 89 + canvas.translate(finalWidth, h) 90 + 91 + data.forEachIndexed { index, point -> 92 + if (point is ViewData.Dummy) return@forEachIndexed 93 + 94 + val column = index / rowsCount 95 + val row = index % rowsCount 96 + canvas.save() 97 + canvas.translate( 98 + - (column + 1) * cellSize, 99 + - (row + 1) * cellSize, 100 + ) 101 + canvas.drawRoundRect( 102 + cellPadding, 103 + cellPadding, 104 + cellSize - cellPadding, 105 + cellSize - cellPadding, 106 + cellRadius, 107 + cellRadius, 108 + if (point is ViewData.Present) cellPresentPaint else cellNotPresentPaint, 109 + ) 110 + canvas.restore() 111 + } 112 + 113 + canvas.restore() 114 + } 115 + 116 + sealed interface ViewData { 117 + object Present : ViewData 118 + object NotPresent : ViewData 119 + object Dummy : ViewData 120 + } 121 + }
+119 -38
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/interactor/StatisticsDetailStreaksInteractor.kt
··· 4 4 import com.example.util.simpletimetracker.core.mapper.TimeMapper 5 5 import com.example.util.simpletimetracker.core.repo.ResourceRepo 6 6 import com.example.util.simpletimetracker.domain.extension.orZero 7 + import com.example.util.simpletimetracker.domain.extension.rotateLeft 7 8 import com.example.util.simpletimetracker.domain.interactor.PrefsInteractor 8 9 import com.example.util.simpletimetracker.domain.mapper.RangeMapper 9 10 import com.example.util.simpletimetracker.domain.model.DayOfWeek ··· 12 13 import com.example.util.simpletimetracker.domain.model.RecordBase 13 14 import com.example.util.simpletimetracker.feature_base_adapter.ViewHolderType 14 15 import com.example.util.simpletimetracker.feature_statistics_detail.R 16 + import com.example.util.simpletimetracker.feature_statistics_detail.customView.SeriesCalendarView 15 17 import com.example.util.simpletimetracker.feature_statistics_detail.customView.SeriesView 16 18 import com.example.util.simpletimetracker.feature_statistics_detail.model.StreaksType 17 19 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailCardViewData ··· 35 37 fun getEmptyStreaksViewData(): StatisticsDetailStreaksViewData { 36 38 return StatisticsDetailStreaksViewData( 37 39 streaks = mapToStatsViewData( 38 - maxStreak = "", 39 - compareMaxStreak = "", 40 + longestStreak = "", 41 + compareLongestStreak = "", 40 42 currentStreak = "", 41 43 compareCurrentStreak = "", 42 44 ), ··· 44 46 data = emptyList(), 45 47 showComparison = false, 46 48 compareData = emptyList(), 49 + showCalendar = false, 50 + calendarData = emptyList(), 51 + showComparisonCalendar = false, 52 + compareCalendarData = emptyList(), 47 53 ) 48 54 } 49 55 ··· 65 71 startOfDayShift = startOfDayShift 66 72 ) 67 73 68 - val (maxStreak, currentStreak, data) = mapStatsData( 74 + val statsData = mapStatsData( 69 75 range = range, 70 76 records = records, 71 77 rangeLength = rangeLength, ··· 73 79 startOfDayShift = startOfDayShift, 74 80 streaksType = streaksType, 75 81 ) 76 - val (compareMaxStreak, compareCurrentStreak, compareData) = if (showComparison) { 82 + val compareStatsData = if (showComparison) { 77 83 mapStatsData( 78 84 range = range, 79 85 records = compareRecords, ··· 83 89 streaksType = streaksType, 84 90 ) 85 91 } else { 86 - Triple(null, null, emptyList()) 92 + null 87 93 } 88 94 89 - fun processMaxStreak(value: Long): String { 95 + fun processLongestStreak(value: Long): String { 90 96 // No point count streak of one day. 91 97 return value.takeUnless { rangeLength is RangeLength.Day } 92 98 ?.toString() ··· 100 106 } 101 107 102 108 val streaks = mapToStatsViewData( 103 - maxStreak = maxStreak 104 - .let(::processMaxStreak), 105 - compareMaxStreak = compareMaxStreak 106 - ?.let(::processMaxStreak) 109 + longestStreak = statsData.longestStreak 110 + .let(::processLongestStreak), 111 + compareLongestStreak = compareStatsData?.longestStreak 112 + ?.let(::processLongestStreak) 107 113 .let(::processComparisonString), 108 - currentStreak = currentStreak 114 + currentStreak = statsData.currentStreak 109 115 .toString(), 110 - compareCurrentStreak = compareCurrentStreak 116 + compareCurrentStreak = compareStatsData?.currentStreak 111 117 ?.toString() 112 118 .let(::processComparisonString), 113 119 ) 114 120 121 + val streaksCalendarCanBeShown = when (rangeLength) { 122 + is RangeLength.Month, 123 + is RangeLength.Year, 124 + is RangeLength.All -> true 125 + 126 + else -> false 127 + } 128 + val streaksCanBeShown = rangeLength !is RangeLength.Day // No point count streak of one day. 129 + val hasDataToShow = streaksCanBeShown && 130 + statsData.rangeCurrentData.size > 1 // one data point would be the same as Longest streak card. 131 + val hasComparisonDataToShow = streaksCanBeShown && 132 + compareStatsData?.rangeCurrentData.orEmpty().size > 1 133 + val hasData = hasDataToShow || hasComparisonDataToShow 134 + 115 135 return@withContext StatisticsDetailStreaksViewData( 116 136 streaks = streaks, 117 - showData = rangeLength !is RangeLength.Day, // No point count streak of one day. 118 - data = data, 119 - showComparison = showComparison, 120 - compareData = compareData, 137 + showData = hasData, 138 + data = statsData.rangeCurrentData, 139 + showComparison = showComparison && hasData, 140 + compareData = compareStatsData?.rangeCurrentData.orEmpty(), 141 + showCalendar = streaksCalendarCanBeShown, 142 + calendarData = statsData.calendarData, 143 + showComparisonCalendar = showComparison && streaksCalendarCanBeShown, 144 + compareCalendarData = compareStatsData?.calendarData.orEmpty(), 121 145 ) 122 146 } 123 147 ··· 152 176 firstDayOfWeek: DayOfWeek, 153 177 startOfDayShift: Long, 154 178 streaksType: StreaksType, 155 - ): Triple<Long, Long, List<SeriesView.ViewData>> { 156 - val (maxStreak, rangeCurrentStreak, rangeCurrentData) = calculate( 179 + ): IntermediateData { 180 + val stats = calculate( 157 181 range = range, 158 182 records = records, 159 183 firstDayOfWeek = firstDayOfWeek, ··· 161 185 streaksType = streaksType, 162 186 ) 163 187 164 - val currentStreak = if (rangeLength is RangeLength.All) { 165 - rangeCurrentStreak 188 + // If range is not all data - calculate current streak on all data. 189 + return if (rangeLength is RangeLength.All) { 190 + stats 166 191 } else { 167 - calculate( 192 + val currentStreak = calculate( 168 193 range = Range(timeStarted = 0, timeEnded = 0), 169 194 records = records, 170 195 firstDayOfWeek = firstDayOfWeek, 171 196 startOfDayShift = startOfDayShift, 172 197 streaksType = streaksType, 173 - ).second 198 + ).currentStreak 199 + stats.copy(currentStreak = currentStreak) 174 200 } 175 - 176 - return Triple(maxStreak, currentStreak, rangeCurrentData) 177 201 } 178 202 179 203 private fun calculate( ··· 182 206 firstDayOfWeek: DayOfWeek, 183 207 startOfDayShift: Long, 184 208 streaksType: StreaksType, 185 - ): Triple<Long, Long, List<SeriesView.ViewData>> { 209 + ): IntermediateData { 186 210 val durations = getRanges( 187 211 range = if (range.timeStarted == 0L && range.timeEnded == 0L) { 188 212 Range( ··· 212 236 } 213 237 214 238 val data: MutableList<Triple<Long, Long, Long>> = mutableListOf() 215 - var maxStreak: Long = 0 239 + var longestStreak: Long = 0 216 240 var counter: Long = 0 217 241 var streakStart: Long = 0 218 242 var streakEnd: Long = 0 ··· 232 256 streakEnd, 233 257 ).let(data::add) 234 258 } 235 - if (counter > maxStreak) maxStreak = counter 259 + if (counter > longestStreak) longestStreak = counter 236 260 } 237 261 if (duration.second <= 0) { 238 262 counter = 0 ··· 245 269 StreaksType.LATEST -> data.sortByDescending { it.third } 246 270 } 247 271 248 - return Triple( 249 - // Series of one day makes no sense. 250 - maxStreak.takeIf { it > 1 }.orZero(), 251 - counter.takeIf { it > 1 }.orZero(), 252 - data.take(MAX_STREAKS_IN_CHART).map { 272 + return IntermediateData( 273 + longestStreak = longestStreak.takeIf { it > 1 }.orZero(), // Series of one day makes no sense. 274 + currentStreak = counter.takeIf { it > 1 }.orZero(), 275 + rangeCurrentData = data.take(MAX_STREAKS_IN_CHART).map { 253 276 SeriesView.ViewData( 254 277 value = it.first, 255 278 legendStart = timeMapper.formatDateYear(it.second), 256 279 legendEnd = timeMapper.formatDateYear(it.third), 257 280 ) 258 - } 281 + }, 282 + calendarData = mapDurationsToCalendarData( 283 + data = durations, 284 + firstDayOfWeek = firstDayOfWeek, 285 + startOfDayShift = startOfDayShift, 286 + ), 259 287 ) 260 288 } 261 289 262 290 private fun mapToStatsViewData( 263 - maxStreak: String, 264 - compareMaxStreak: String, 291 + longestStreak: String, 292 + compareLongestStreak: String, 265 293 currentStreak: String, 266 294 compareCurrentStreak: String, 267 295 ): List<StatisticsDetailCardViewData> { 268 296 return listOf( 269 297 StatisticsDetailCardViewData( 270 - value = maxStreak, 298 + value = longestStreak, 271 299 valueChange = StatisticsDetailCardViewData.ValueChange.None, 272 - secondValue = compareMaxStreak, 300 + secondValue = compareLongestStreak, 273 301 description = resourceRepo.getString(R.string.statistics_detail_streaks_longest) 274 302 ), 275 303 StatisticsDetailCardViewData( ··· 323 351 private fun getDailyGroupings( 324 352 startDate: Long, 325 353 numberOfDays: Int, 326 - startOfDayShift: Long, 354 + startOfDayShift: Long, // TODO setting +2 shows 32 streak in a 31 day month. Seems wrong. 327 355 ): List<Range> { 328 356 val calendar = Calendar.getInstance() 329 357 ··· 343 371 ) 344 372 } 345 373 } 374 + 375 + // Data is in format timeStarted to duration. 376 + private fun mapDurationsToCalendarData( 377 + data: List<Pair<Long, Long>>, 378 + firstDayOfWeek: DayOfWeek, 379 + startOfDayShift: Long, 380 + ): List<SeriesCalendarView.ViewData> { 381 + val days = listOf( 382 + DayOfWeek.MONDAY, 383 + DayOfWeek.TUESDAY, 384 + DayOfWeek.WEDNESDAY, 385 + DayOfWeek.THURSDAY, 386 + DayOfWeek.FRIDAY, 387 + DayOfWeek.SATURDAY, 388 + DayOfWeek.SUNDAY, 389 + ).let { list -> 390 + list.indexOf(firstDayOfWeek) 391 + .takeUnless { it == -1 }.orZero() 392 + .let(list::rotateLeft) 393 + }.reversed() 394 + 395 + val calendar = Calendar.getInstance() 396 + // Take last because data is from range start to range end. 397 + val endDayOfWeek: DayOfWeek? = data.lastOrNull()?.first?.let { 398 + calendar 399 + .apply { 400 + timeInMillis = it 401 + timeInMillis -= startOfDayShift 402 + } 403 + .get(Calendar.DAY_OF_WEEK) 404 + .let(timeMapper::toDayOfWeek) 405 + } 406 + // If for example today is wednesday - need to add 4 dummy days to show correct position on view. 407 + val daysToAdd = days.indexOfFirst { it == endDayOfWeek }.takeUnless { it == -1 }.orZero() 408 + val dummyDays = List(daysToAdd) { SeriesCalendarView.ViewData.Dummy } 409 + 410 + return dummyDays + data 411 + .map { 412 + if (it.second > 0) { 413 + SeriesCalendarView.ViewData.Present 414 + } else { 415 + SeriesCalendarView.ViewData.NotPresent 416 + } 417 + } 418 + .reversed() 419 + } 420 + 421 + private data class IntermediateData( 422 + val longestStreak: Long, 423 + val currentStreak: Long, 424 + val rangeCurrentData: List<SeriesView.ViewData>, 425 + val calendarData: List<SeriesCalendarView.ViewData>, 426 + ) 346 427 347 428 companion object { 348 429 private const val MAX_STREAKS_IN_CHART = 10
+7
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/view/StatisticsDetailFragment.kt
··· 182 182 chartStatisticsDetailSplit.setBarColor(first.color) 183 183 chartStatisticsDetailDurationSplit.setBarColor(first.color) 184 184 chartStatisticsStreaks.setBarColor(first.color) 185 + chartStatisticsStreaksCalendar.setCellColor(first.color) 185 186 186 187 viewData.comparisonData 187 188 .filterIsInstance<StatisticsDetailPreviewViewData>() ··· 211 212 viewData: StatisticsDetailStreaksViewData, 212 213 ) = with(binding) { 213 214 cardStatisticsDetailStreaks.items = viewData.streaks 215 + 214 216 chartStatisticsStreaks.setData(viewData.data) 215 217 chartStatisticsStreaks.isVisible = viewData.showData 216 218 chartStatisticsComparisonStreaks.setData(viewData.compareData) 217 219 chartStatisticsComparisonStreaks.isVisible = viewData.showComparison 220 + buttonsStatisticsDetailStreaksType.isVisible = viewData.showData 221 + 222 + chartStatisticsStreaksCalendar.isVisible = viewData.showCalendar 223 + chartStatisticsStreaksCalendar.setData(viewData.calendarData) 224 + // TODO add compare calendar 218 225 } 219 226 220 227 private fun updateChartViewData(
+5
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/viewData/StatisticsDetailStreaksViewData.kt
··· 1 1 package com.example.util.simpletimetracker.feature_statistics_detail.viewData 2 2 3 + import com.example.util.simpletimetracker.feature_statistics_detail.customView.SeriesCalendarView 3 4 import com.example.util.simpletimetracker.feature_statistics_detail.customView.SeriesView 4 5 5 6 data class StatisticsDetailStreaksViewData( ··· 8 9 val data: List<SeriesView.ViewData>, 9 10 val showComparison: Boolean, 10 11 val compareData: List<SeriesView.ViewData>, 12 + val showCalendar: Boolean, 13 + val calendarData: List<SeriesCalendarView.ViewData>, 14 + val showComparisonCalendar: Boolean, 15 + val compareCalendarData: List<SeriesCalendarView.ViewData>, 11 16 )