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 record length popup on statistics detail

razeeman (Jun 10, 2023, 9:42 AM +0300) 216a75e6 9d070cc4

+230 -27
+63 -5
app/src/main/java/com/example/util/simpletimetracker/navigation/NotificationResolverImpl.kt
··· 3 3 import android.annotation.SuppressLint 4 4 import android.app.Activity 5 5 import android.graphics.Color 6 + import android.graphics.Rect 6 7 import android.view.View 8 + import android.view.View.MeasureSpec 9 + import android.view.ViewGroup 10 + import android.widget.PopupWindow 7 11 import android.widget.TextView 8 12 import android.widget.Toast 13 + import com.example.util.simpletimetracker.core.databinding.ViewPopupLayoutBinding 9 14 import com.example.util.simpletimetracker.feature_views.extension.setMargins 10 15 import com.example.util.simpletimetracker.feature_views.extension.setOnClick 11 16 import com.example.util.simpletimetracker.navigation.params.notification.NotificationParams 17 + import com.example.util.simpletimetracker.navigation.params.notification.PopupParams 12 18 import com.example.util.simpletimetracker.navigation.params.notification.SnackBarParams 13 19 import com.example.util.simpletimetracker.navigation.params.notification.ToastParams 14 20 import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_INDEFINITE 15 21 import com.google.android.material.snackbar.Snackbar 22 + import java.lang.Integer.max 16 23 import javax.inject.Inject 17 24 18 25 class NotificationResolverImpl @Inject constructor() : NotificationResolver { 19 26 20 27 override fun show(activity: Activity?, data: NotificationParams, anchor: Any?) { 28 + if (activity == null) return 21 29 when (data) { 22 30 is ToastParams -> showSystemMessage(activity, data) 23 31 is SnackBarParams -> showSnackBar(activity, data, anchor) 32 + is PopupParams -> showPopup(activity, data) 24 33 } 25 34 } 26 35 27 - private fun showSystemMessage(activity: Activity?, data: ToastParams) { 28 - Toast.makeText(activity?.applicationContext, data.message, Toast.LENGTH_LONG).show() 36 + private fun showSystemMessage(activity: Activity, data: ToastParams) { 37 + Toast.makeText(activity.applicationContext, data.message, Toast.LENGTH_LONG).show() 29 38 } 30 39 31 40 @SuppressLint("WrongConstant") 32 - private fun showSnackBar(activity: Activity?, data: SnackBarParams, anchor: Any?) { 33 - if (activity == null) return 34 - 41 + private fun showSnackBar(activity: Activity, data: SnackBarParams, anchor: Any?) { 35 42 val snackBar = Snackbar.make( 36 43 activity.findViewById(android.R.id.content), 37 44 data.message, ··· 83 90 } 84 91 85 92 snackBar.show() 93 + } 94 + 95 + @SuppressLint("ClickableViewAccessibility") 96 + private fun showPopup(activity: Activity, data: PopupParams) { 97 + val parent = activity.window.decorView 98 + 99 + val view = ViewPopupLayoutBinding.inflate(activity.layoutInflater) 100 + view.tvPopupText.text = data.message 101 + val popupView = PopupWindow( 102 + view.root, 103 + ViewGroup.LayoutParams.WRAP_CONTENT, 104 + ViewGroup.LayoutParams.WRAP_CONTENT 105 + ) 106 + popupView.apply { 107 + isFocusable = false 108 + isTouchable = true 109 + isOutsideTouchable = true 110 + } 111 + view.root.setOnTouchListener { _, _ -> 112 + popupView.dismiss() 113 + true 114 + } 115 + 116 + view.root.measure( 117 + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), 118 + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), 119 + ) 120 + val viewWidth = view.root.measuredWidth 121 + val viewHeight = view.root.measuredHeight 122 + val availableSpace = Rect() 123 + parent.getWindowVisibleDisplayFrame(availableSpace) 124 + 125 + val canShowAtBottom = viewHeight <= availableSpace.bottom - data.anchorCoordinates.bottom 126 + val offsetX = data.anchorCoordinates.right - data.anchorCoordinates.width / 2 - viewWidth / 2 127 + val offsetY = if (canShowAtBottom) { 128 + data.anchorCoordinates.bottom 129 + } else { 130 + data.anchorCoordinates.top - viewHeight 131 + } 132 + 133 + popupView.showAsDropDown( 134 + parent, 135 + offsetX.coerceIn( 136 + availableSpace.left, 137 + max(availableSpace.left, availableSpace.right - viewWidth), 138 + ), 139 + offsetY.coerceIn( 140 + availableSpace.top, 141 + max(availableSpace.top, availableSpace.bottom - viewHeight), 142 + ), 143 + ) 86 144 } 87 145 88 146 companion object {
+14
core/src/main/java/com/example/util/simpletimetracker/core/extension/OtherExtensions.kt
··· 1 1 package com.example.util.simpletimetracker.core.extension 2 2 3 3 import android.content.BroadcastReceiver 4 + import android.graphics.Rect 4 5 import android.os.StrictMode 6 + import android.view.View 5 7 import androidx.lifecycle.LifecycleOwner 6 8 import androidx.viewpager2.widget.ViewPager2 7 9 import com.example.util.simpletimetracker.core.utils.getLifecycleObserverAdapter 10 + import com.example.util.simpletimetracker.domain.model.Coordinates 8 11 import java.util.Calendar 9 12 import kotlinx.coroutines.CoroutineScope 10 13 import kotlinx.coroutines.DelicateCoroutinesApi ··· 82 85 getLifecycleObserverAdapter( 83 86 onDestroy = { unregisterOnPageChangeCallback(callback) } 84 87 ).let(lifecycleOwner.lifecycle::addObserver) 88 + } 89 + 90 + fun View.getCoordinates(): Coordinates { 91 + val rect = Rect() 92 + getGlobalVisibleRect(rect) 93 + return Coordinates( 94 + left = rect.left, 95 + top = rect.top, 96 + right = rect.right, 97 + bottom = rect.bottom, 98 + ) 85 99 }
+20
core/src/main/res/layout/view_popup_layout.xml
··· 1 + <?xml version="1.0" encoding="utf-8"?> 2 + <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" 3 + xmlns:app="http://schemas.android.com/apk/res-auto" 4 + xmlns:tools="http://schemas.android.com/tools" 5 + android:layout_width="wrap_content" 6 + android:layout_height="wrap_content" 7 + app:cardBackgroundColor="?appInactiveColor" 8 + app:cardCornerRadius="@dimen/widget_universal_corner_radius" 9 + app:cardElevation="0dp"> 10 + 11 + <androidx.appcompat.widget.AppCompatTextView 12 + android:id="@+id/tvPopupText" 13 + android:layout_width="wrap_content" 14 + android:layout_height="wrap_content" 15 + android:gravity="center" 16 + android:padding="16dp" 17 + android:textColor="?appLightTextColor" 18 + tools:text="I'm a popup!" /> 19 + 20 + </androidx.cardview.widget.CardView>
+12
domain/src/main/java/com/example/util/simpletimetracker/domain/model/Coordinates.kt
··· 1 + package com.example.util.simpletimetracker.domain.model 2 + 3 + data class Coordinates( 4 + val left: Int, 5 + val top: Int, 6 + val right: Int, 7 + val bottom: Int, 8 + ) { 9 + 10 + val width: Int get() = right - left 11 + val height: Int get() = bottom - top 12 + }
+8 -2
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/adapter/StatisticsDetailCardAdapterDelegate.kt
··· 4 4 import android.graphics.Typeface 5 5 import android.util.TypedValue 6 6 import androidx.core.view.ViewCompat 7 + import com.example.util.simpletimetracker.core.extension.getCoordinates 8 + import com.example.util.simpletimetracker.domain.model.Coordinates 7 9 import com.example.util.simpletimetracker.feature_base_adapter.createRecyclerBindingAdapterDelegate 8 10 import com.example.util.simpletimetracker.feature_base_adapter.extensions.getThemedAttr 9 11 import com.example.util.simpletimetracker.feature_statistics_detail.R ··· 15 17 fun createStatisticsDetailCardAdapterDelegate( 16 18 titleTextSize: Int, 17 19 subtitleTextSize: Int, 18 - onItemClick: () -> Unit 20 + onItemClick: (ViewData.ClickableType, Coordinates) -> Unit 19 21 ) = createRecyclerBindingAdapterDelegate<ViewData, Binding>( 20 22 Binding::inflate 21 23 ) { binding, item, _ -> ··· 58 60 ivStatisticsDetailCardIcon, 59 61 ColorStateList.valueOf(item.icon.iconColor) 60 62 ) 61 - root.setOnClick(onItemClick) 62 63 } else { 63 64 cardStatisticsDetailCardIcon.visible = false 65 + } 66 + 67 + if (item.clickable != null) { 68 + root.setOnClick { onItemClick(item.clickable, root.getCoordinates()) } 69 + } else { 64 70 root.isClickable = false 65 71 } 66 72 }
+7 -3
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/customView/StatisticsDetailCardView.kt
··· 6 6 import android.widget.FrameLayout 7 7 import androidx.core.content.res.ResourcesCompat 8 8 import androidx.core.view.updatePadding 9 + import com.example.util.simpletimetracker.domain.model.Coordinates 9 10 import com.example.util.simpletimetracker.feature_base_adapter.BaseRecyclerAdapter 10 11 import com.example.util.simpletimetracker.feature_views.extension.dpToPx 11 12 import com.example.util.simpletimetracker.feature_views.extension.spToPx ··· 30 31 defStyleAttr 31 32 ) { 32 33 33 - var listener: (() -> Unit)? = null 34 + var listener: (StatisticsDetailCardViewData.ClickableType, Coordinates) -> Unit = { _, _ -> } 34 35 var itemsDescription: String = "" 35 36 set(value) { 36 37 binding.tvStatisticsDetailCardDescription.text = value ··· 121 122 } 122 123 } 123 124 124 - private fun onItemClick() { 125 - listener?.invoke() 125 + private fun onItemClick( 126 + type: StatisticsDetailCardViewData.ClickableType, 127 + coordinates: Coordinates 128 + ) { 129 + listener(type, coordinates) 126 130 } 127 131 128 132 companion object {
+44 -6
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/interactor/StatisticsDetailStatsInteractor.kt
··· 20 20 import com.example.util.simpletimetracker.feature_base_adapter.statisticsTag.StatisticsTagViewData 21 21 import com.example.util.simpletimetracker.feature_statistics_detail.R 22 22 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailCardViewData 23 + import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailClickableLongest 24 + import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailClickableShortest 25 + import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailClickableTracked 23 26 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailStatsViewData 24 27 import com.example.util.simpletimetracker.feature_views.viewData.RecordTypeIcon 25 28 import javax.inject.Inject ··· 93 96 timesTrackedIcon = null, 94 97 shortestRecord = "", 95 98 compareShortestRecord = "", 99 + shortestRecordDate = "", 96 100 averageRecord = "", 97 101 compareAverageRecord = "", 98 102 longestRecord = "", 99 103 compareLongestRecord = "", 104 + longestRecordDate = "", 100 105 firstRecord = "", 101 106 compareFirstRecord = "", 102 107 lastRecord = "", ··· 116 121 useProportionalMinutes: Boolean, 117 122 showSeconds: Boolean, 118 123 ): StatisticsDetailStatsViewData { 124 + val typesMap = types.associateBy { it.id } 119 125 val recordsSorted = records.sortedBy { it.timeStarted } 120 126 val durations = records.map(RecordBase::duration) 121 127 122 128 val compareRecordsSorted = compareRecords.sortedBy { it.timeStarted } 123 129 val compareDurations = compareRecords.map(RecordBase::duration) 124 130 131 + val shortestRecord = records.minByOrNull(RecordBase::duration) 132 + val longestRecord = records.maxByOrNull(RecordBase::duration) 133 + 125 134 val emptyValue by lazy { 126 135 resourceRepo.getString(R.string.statistics_detail_empty) 127 136 } ··· 135 144 ) 136 145 val activitySplitData = mapActivities( 137 146 records = records, 138 - typesMap = types.associateBy { it.id }, 147 + typesMap = typesMap, 139 148 isDarkTheme = isDarkTheme, 140 149 useProportionalMinutes = useProportionalMinutes, 141 150 showSeconds = showSeconds, 142 151 ) 143 152 val tagSplitData = mapTags( 144 153 records = records, 145 - typesMap = types.associateBy { it.id }, 154 + typesMap = typesMap, 146 155 tagsMap = tags.associateBy { it.id }, 147 156 isDarkTheme = isDarkTheme, 148 157 useProportionalMinutes = useProportionalMinutes, ··· 178 187 .orEmpty() 179 188 } 180 189 190 + fun processLengthHint(value: RecordBase?): String { 191 + value ?: return emptyValue 192 + 193 + val result = StringBuilder() 194 + value.typeIds 195 + .mapNotNull(typesMap::get) 196 + .map(RecordType::name) 197 + .takeUnless { it.isEmpty() } 198 + ?.joinToString() 199 + ?.let { 200 + result.append(it) 201 + result.append("\n") 202 + } 203 + value.timeStarted 204 + .let(::formatDateTimeYear) 205 + .let { result.append(it) } 206 + 207 + return result.toString() 208 + } 209 + 181 210 return mapToStatsViewData( 182 211 totalDuration = durations.sum() 183 212 .let(::formatInterval), ··· 188 217 compareTimesTracked = compareRecords.size.toString() 189 218 .let(::processComparisonString), 190 219 timesTrackedIcon = recordsAllIcon, 191 - shortestRecord = durations.minOrNull() 220 + shortestRecord = shortestRecord?.duration 192 221 .let(::formatInterval), 193 222 compareShortestRecord = compareDurations.minOrNull() 194 223 .let(::formatInterval) 195 224 .let(::processComparisonString), 225 + shortestRecordDate = shortestRecord 226 + .let(::processLengthHint), 196 227 averageRecord = getAverage(durations) 197 228 .let(::formatInterval), 198 229 compareAverageRecord = getAverage(compareDurations) 199 230 .let(::formatInterval) 200 231 .let(::processComparisonString), 201 - longestRecord = durations.maxOrNull() 232 + longestRecord = longestRecord?.duration 202 233 .let(::formatInterval), 203 234 compareLongestRecord = compareDurations.maxOrNull() 204 235 .let(::formatInterval) 205 236 .let(::processComparisonString), 237 + longestRecordDate = longestRecord 238 + .let(::processLengthHint), 206 239 firstRecord = recordsSorted.firstOrNull()?.timeStarted 207 240 .let(::formatDateTimeYear), 208 241 compareFirstRecord = compareRecordsSorted.firstOrNull()?.timeStarted ··· 225 258 timesTrackedIcon: StatisticsDetailCardViewData.Icon?, 226 259 shortestRecord: String, 227 260 compareShortestRecord: String, 261 + shortestRecordDate: String, 228 262 averageRecord: String, 229 263 compareAverageRecord: String, 230 264 longestRecord: String, 231 265 compareLongestRecord: String, 266 + longestRecordDate: String, 232 267 firstRecord: String, 233 268 compareFirstRecord: String, 234 269 lastRecord: String, ··· 254 289 R.plurals.statistics_detail_times_tracked, timesTracked.orZero() 255 290 ), 256 291 icon = timesTrackedIcon, 292 + clickable = StatisticsDetailClickableTracked, 257 293 accented = true, 258 294 ) 259 295 ), ··· 262 298 value = shortestRecord, 263 299 valueChange = StatisticsDetailCardViewData.ValueChange.None, 264 300 secondValue = compareShortestRecord, 265 - description = resourceRepo.getString(R.string.statistics_detail_shortest_record) 301 + description = resourceRepo.getString(R.string.statistics_detail_shortest_record), 302 + clickable = StatisticsDetailClickableShortest(shortestRecordDate), 266 303 ), 267 304 StatisticsDetailCardViewData( 268 305 value = averageRecord, ··· 274 311 value = longestRecord, 275 312 valueChange = StatisticsDetailCardViewData.ValueChange.None, 276 313 secondValue = compareLongestRecord, 277 - description = resourceRepo.getString(R.string.statistics_detail_longest_record) 314 + description = resourceRepo.getString(R.string.statistics_detail_longest_record), 315 + clickable = StatisticsDetailClickableLongest(longestRecordDate), 278 316 ) 279 317 ), 280 318 datesTracked = listOf(
+2 -1
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/view/StatisticsDetailFragment.kt
··· 100 100 buttonsStatisticsDetailGrouping.listener = viewModel::onChartGroupingClick 101 101 buttonsStatisticsDetailLength.listener = viewModel::onChartLengthClick 102 102 buttonsStatisticsDetailSplitGrouping.listener = viewModel::onSplitChartGroupingClick 103 - cardStatisticsDetailRecords.listener = throttle(viewModel::onRecordsClick) 103 + cardStatisticsDetailRecords.listener = throttle(viewModel::onCardClick) 104 + cardStatisticsDetailAverage.listener = throttle(viewModel::onCardClick) 104 105 spinnerStatisticsDetail.onItemSelected = viewModel::onRangeSelected 105 106 btnStatisticsDetailPrevious.setOnClick(viewModel::onPreviousClick) 106 107 btnStatisticsDetailNext.setOnClick(viewModel::onNextClick)
+11
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/viewData/StatisticsDetailCardClickableTypes.kt
··· 1 + package com.example.util.simpletimetracker.feature_statistics_detail.viewData 2 + 3 + object StatisticsDetailClickableTracked : StatisticsDetailCardViewData.ClickableType 4 + 5 + data class StatisticsDetailClickableShortest( 6 + val message: String, 7 + ) : StatisticsDetailCardViewData.ClickableType 8 + 9 + data class StatisticsDetailClickableLongest( 10 + val message: String, 11 + ) : StatisticsDetailCardViewData.ClickableType
+3
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/viewData/StatisticsDetailCardViewData.kt
··· 10 10 val secondValue: String, 11 11 val description: String, 12 12 val icon: Icon? = null, 13 + val clickable: ClickableType? = null, 13 14 val accented: Boolean = false, 14 15 ) : ViewHolderType { 15 16 ··· 29 30 @ColorInt val color: Int, 30 31 ) : ValueChange 31 32 } 33 + 34 + interface ClickableType 32 35 }
+37 -9
features/feature_statistics_detail/src/main/java/com/example/util/simpletimetracker/feature_statistics_detail/viewModel/StatisticsDetailViewModel.kt
··· 17 17 import com.example.util.simpletimetracker.core.viewData.SelectDateViewData 18 18 import com.example.util.simpletimetracker.core.viewData.SelectRangeViewData 19 19 import com.example.util.simpletimetracker.domain.interactor.PrefsInteractor 20 + import com.example.util.simpletimetracker.domain.model.Coordinates 20 21 import com.example.util.simpletimetracker.domain.model.Range 21 22 import com.example.util.simpletimetracker.domain.model.RangeLength 22 23 import com.example.util.simpletimetracker.domain.model.RecordBase ··· 37 38 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailChartCompositeViewData 38 39 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailChartLengthViewData 39 40 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailChartViewData 41 + import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailClickableLongest 42 + import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailClickableShortest 43 + import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailClickableTracked 40 44 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailGroupingViewData 41 45 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailPreviewCompositeViewData 42 46 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailPreviewViewData ··· 46 50 import com.example.util.simpletimetracker.feature_statistics_detail.viewData.StatisticsDetailStreaksViewData 47 51 import com.example.util.simpletimetracker.feature_views.spinner.CustomSpinner 48 52 import com.example.util.simpletimetracker.navigation.Router 53 + import com.example.util.simpletimetracker.navigation.params.notification.PopupParams 49 54 import com.example.util.simpletimetracker.navigation.params.screen.CustomRangeSelectionParams 50 55 import com.example.util.simpletimetracker.navigation.params.screen.DateTimeDialogParams 51 56 import com.example.util.simpletimetracker.navigation.params.screen.DateTimeDialogType ··· 225 230 updateSplitChartViewData() 226 231 } 227 232 228 - fun onRecordsClick() { 229 - viewModelScope.launch { 230 - val dateFilter = recordFilterInteractor.mapDateFilter(rangeLength, rangePosition) 231 - ?.let(::listOf).orEmpty() 232 - val finalFilters = filter 233 - .plus(dateFilter) 234 - .map(RecordsFilter::toParams).toList() 235 - 236 - router.navigate(RecordsAllParams(finalFilters)) 233 + fun onCardClick( 234 + type: StatisticsDetailCardViewData.ClickableType, 235 + coordinates: Coordinates, 236 + ) { 237 + when (type) { 238 + is StatisticsDetailClickableTracked -> { 239 + onRecordsClick() 240 + } 241 + is StatisticsDetailClickableShortest -> { 242 + PopupParams( 243 + message = type.message, 244 + anchorCoordinates = coordinates, 245 + ).let(router::show) 246 + } 247 + is StatisticsDetailClickableLongest -> { 248 + PopupParams( 249 + message = type.message, 250 + anchorCoordinates = coordinates, 251 + ).let(router::show) 252 + } 237 253 } 238 254 } 239 255 ··· 281 297 fun onCustomRangeSelected(range: Range) { 282 298 rangeLength = RangeLength.Custom(range) 283 299 onRangeChanged() 300 + } 301 + 302 + private fun onRecordsClick() { 303 + viewModelScope.launch { 304 + val dateFilter = recordFilterInteractor.mapDateFilter(rangeLength, rangePosition) 305 + ?.let(::listOf).orEmpty() 306 + val finalFilters = filter 307 + .plus(dateFilter) 308 + .map(RecordsFilter::toParams).toList() 309 + 310 + router.navigate(RecordsAllParams(finalFilters)) 311 + } 284 312 } 285 313 286 314 private fun onSelectDateClick() = viewModelScope.launch {
+1 -1
navigation/src/main/java/com/example/util/simpletimetracker/navigation/params/notification/NotificationParams.kt
··· 1 1 package com.example.util.simpletimetracker.navigation.params.notification 2 2 3 - interface NotificationParams 3 + sealed interface NotificationParams
+8
navigation/src/main/java/com/example/util/simpletimetracker/navigation/params/notification/PopupParams.kt
··· 1 + package com.example.util.simpletimetracker.navigation.params.notification 2 + 3 + import com.example.util.simpletimetracker.domain.model.Coordinates 4 + 5 + data class PopupParams( 6 + val message: String, 7 + val anchorCoordinates: Coordinates, 8 + ) : NotificationParams