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.

feat(wear): Show tag colors in TagList

Requires pulling tag colors from the phone (which
itself requires reconciling the color based on
whether or not the tag is a general tag), then
rendering them in the tag chip.

Also took the liberty to switch the tag chip from
a ToggleChip base to a SplitToggleChip base, which
(1) looks better (i.e. more consistent with the
ActivitiesList) and (2) allows multi-taggers to
start an activity immediately upon selecting one
tag, if desired.

Joseph Hale (Feb 26, 2024, 11:57 PM -0700) a45ccadc 9ec36f1a

+146 -63
+30 -9
app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt
··· 11 11 import com.example.util.simpletimetracker.domain.interactor.RemoveRunningRecordMediator 12 12 import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor 13 13 import com.example.util.simpletimetracker.domain.mapper.AppColorMapper 14 + import com.example.util.simpletimetracker.domain.model.AppColor 14 15 import com.example.util.simpletimetracker.domain.model.RecordTag 15 16 import com.example.util.simpletimetracker.domain.model.RunningRecord 16 17 import com.example.util.simpletimetracker.wearrpc.Activity ··· 31 32 override suspend fun queryActivities(): Array<Activity> { 32 33 return recordTypeInteractor.getAll().filter { recordType -> !recordType.hidden } 33 34 .map { recordType -> 34 - val color = appColorMapper.mapToColorInt(recordType.color) 35 35 Activity( 36 36 id = recordType.id, 37 37 name = recordType.name, 38 38 icon = recordType.icon, 39 - color = color.toLong(), 39 + color = asColor(recordType.color), 40 40 ) 41 41 }.toTypedArray() 42 42 } ··· 72 72 } 73 73 74 74 override suspend fun queryTagsForActivity(activityId: Long): Array<Tag> { 75 + val activityColor = recordTypeInteractor.get(activityId)?.color 75 76 return recordTagInteractor.getByTypeOrUntyped(activityId).filter { !it.archived } 76 - .map { asTag(it) }.toTypedArray() 77 + .map { asTag(it, asColor(activityColor)) }.sortedBy { it.name } 78 + .sortedBy { it.isGeneral }.toTypedArray() 79 + } 80 + 81 + private fun asTag(recordTag: RecordTag?, activityColor: Long = 0x00000000): Tag { 82 + return if (recordTag != null) { 83 + val isGeneral = recordTag.typeId == 0L 84 + val tagColor = if (isGeneral) { 85 + asColor(recordTag.color) 86 + } else { 87 + activityColor 88 + } 89 + Tag( 90 + id = recordTag.id, 91 + name = recordTag.name, 92 + isGeneral = isGeneral, 93 + color = tagColor, 94 + ) 95 + } else { 96 + Tag(id = -1, name = "", isGeneral = true, color = 0xFF555555) 97 + } 77 98 } 78 99 79 - private fun asTag(recordTag: RecordTag?): Tag { 80 - return Tag( 81 - id = recordTag?.id ?: -1, 82 - name = recordTag?.name ?: "", 83 - isGeneral = recordTag?.typeId == 0L, 84 - ) 100 + private fun asColor(appColor: AppColor?): Long { 101 + return if (appColor == null) { 102 + 0x00000000 103 + } else { 104 + appColorMapper.mapToColorInt(appColor).toLong() 105 + } 85 106 } 86 107 87 108 override suspend fun querySettings(): Settings {
+1 -1
wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt
··· 43 43 activity, 44 44 startedAt = currentActivity?.startedAt, 45 45 tags = currentActivity?.tags ?: arrayOf(), 46 - onPress = { onSelectActivity(activity) }, 46 + onClick = { onSelectActivity(activity) }, 47 47 onToggleOn = { onEnableActivity(activity) }, 48 48 onToggleOff = { onDisableActivity(activity) }, 49 49 )
+4 -4
wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt
··· 36 36 activity: Activity, 37 37 startedAt: Long? = null, 38 38 tags: Array<Tag> = arrayOf(), 39 - onPress: () -> Unit = {}, 39 + onClick: () -> Unit = {}, 40 40 onToggleOn: () -> Unit = {}, 41 41 onToggleOff: () -> Unit = {}, 42 42 ) { ··· 89 89 } 90 90 }, 91 91 checked = switchChecked, 92 - onClick = onPress, 92 + onClick = onClick, 93 93 toggleControl = { 94 94 Switch( 95 95 checked = switchChecked, ··· 173 173 ActivityChip( 174 174 Activity(456, "Sleeping", "🛏️", 0xFFABCDEF), startedAt = 1706751601000L, 175 175 tags = arrayOf( 176 - Tag(id = 2, name = "Work", isGeneral = false), 177 - Tag(id = 4, name = "Hotel", isGeneral = false), 176 + Tag(id = 2, name = "Work", isGeneral = true, color = 0xFFFFAA22), 177 + Tag(id = 4, name = "Hotel", isGeneral = false, color = 0xFFABCDEF), 178 178 ), 179 179 ) 180 180 }
+71 -13
wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt
··· 13 13 import androidx.compose.runtime.remember 14 14 import androidx.compose.runtime.setValue 15 15 import androidx.compose.ui.Modifier 16 + import androidx.compose.ui.graphics.Color 16 17 import androidx.compose.ui.text.style.TextOverflow 17 18 import androidx.compose.ui.tooling.preview.Preview 18 19 import androidx.compose.ui.unit.dp 19 20 import androidx.wear.compose.material.Checkbox 21 + import androidx.wear.compose.material.CheckboxDefaults 20 22 import androidx.wear.compose.material.Chip 23 + import androidx.wear.compose.material.ChipDefaults 24 + import androidx.wear.compose.material.SplitToggleChip 21 25 import androidx.wear.compose.material.Text 22 - import androidx.wear.compose.material.ToggleChip 26 + import androidx.wear.compose.material.ToggleChipDefaults 23 27 import androidx.wear.tooling.preview.devices.WearDevices 24 28 import com.example.util.simpletimetracker.wearrpc.Tag 25 29 ··· 28 32 } 29 33 30 34 @Composable 31 - fun TagChip(tag: Tag, onClick: () -> Unit, mode: TagSelectionMode = TagSelectionMode.SINGLE) { 35 + fun TagChip( 36 + tag: Tag, 37 + onClick: () -> Unit = {}, 38 + onToggleOn: () -> Unit = {}, 39 + onToggleOff: () -> Unit = {}, 40 + mode: TagSelectionMode = TagSelectionMode.SINGLE, 41 + ) { 32 42 when (mode) { 33 43 TagSelectionMode.SINGLE -> { 34 44 SingleSelectTagChip(tag = tag, onClick = onClick) 35 45 } 36 46 37 47 TagSelectionMode.MULTI -> { 38 - MultiSelectTagChip(tag = tag, onClick = onClick) 48 + MultiSelectTagChip( 49 + tag = tag, 50 + onClick = onClick, 51 + onToggleOn = onToggleOn, 52 + onToggleOff = onToggleOff, 53 + ) 39 54 } 40 55 } 41 56 ··· 46 61 Chip( 47 62 onClick = onClick, 48 63 label = { Text(tag.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, 64 + colors = ChipDefaults.chipColors(backgroundColor = Color(tag.color)), 49 65 modifier = Modifier 50 66 .fillMaxWidth(0.9f) 51 67 .padding(top = 10.dp), ··· 53 69 } 54 70 55 71 @Composable 56 - private fun MultiSelectTagChip(tag: Tag, onClick: () -> Unit) { 57 - var checked by remember { mutableStateOf(false) } 58 - ToggleChip( 59 - checked = checked, 72 + private fun MultiSelectTagChip( 73 + tag: Tag, 74 + onClick: () -> Unit = {}, 75 + onToggleOn: () -> Unit = {}, 76 + onToggleOff: () -> Unit = {}, 77 + checked: Boolean = false, 78 + ) { 79 + var _checked by remember { mutableStateOf(checked) } 80 + SplitToggleChip( 81 + checked = _checked, 60 82 onCheckedChange = { 61 - checked = !checked 62 - onClick() 83 + _checked = !_checked 84 + if (_checked) { 85 + onToggleOn() 86 + } else { 87 + onToggleOff() 88 + } 63 89 }, 90 + onClick = onClick, 64 91 label = { Text(tag.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, 65 - toggleControl = { Checkbox(checked = checked) }, 92 + toggleControl = { 93 + Checkbox( 94 + checked = _checked, 95 + colors = CheckboxDefaults.colors( 96 + checkedBoxColor = Color.White, 97 + checkedCheckmarkColor = Color.White, 98 + uncheckedBoxColor = Color.White, 99 + uncheckedCheckmarkColor = Color.White, 100 + ), 101 + ) 102 + }, 103 + colors = ToggleChipDefaults.splitToggleChipColors( 104 + backgroundColor = Color(tag.color), 105 + splitBackgroundOverlayColor = if (_checked) { 106 + Color.White.copy(alpha = .1F) 107 + } else { 108 + Color.Black.copy(alpha = .3F) 109 + }, 110 + ), 66 111 modifier = Modifier 67 112 .fillMaxWidth(0.9f) 68 - .padding(top = 10.dp) 113 + .padding(top = 10.dp), 69 114 ) 70 115 } 71 116 72 117 @Preview(device = WearDevices.LARGE_ROUND) 73 118 @Composable 74 119 private fun Default() { 75 - TagChip(tag = Tag(id = 123, name = "Sleep", isGeneral = false), onClick = {}) 120 + TagChip( 121 + tag = Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF123456), 122 + onClick = {}, 123 + ) 76 124 } 77 125 78 126 ··· 80 128 @Composable 81 129 private fun MultiSelectMode() { 82 130 TagChip( 83 - tag = Tag(id = 123, name = "Sleep", isGeneral = false), 131 + tag = Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF654321), 84 132 onClick = {}, 85 133 mode = TagSelectionMode.MULTI, 134 + ) 135 + } 136 + 137 + @Preview(device = WearDevices.LARGE_ROUND) 138 + @Composable 139 + private fun MultiSelectChecked() { 140 + MultiSelectTagChip( 141 + tag = Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF654321), 142 + onClick = {}, 143 + checked = true, 86 144 ) 87 145 }
+8 -17
wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt
··· 42 42 TagChip( 43 43 tag = tag, mode = mode, 44 44 onClick = { 45 - when (mode) { 46 - TagSelectionMode.SINGLE -> { 47 - onSelectionComplete(arrayOf(tag)) 48 - } 49 - 50 - TagSelectionMode.MULTI -> { 51 - if (selectedTags.contains(tag)) { 52 - selectedTags = selectedTags.minus(tag) 53 - } else { 54 - selectedTags = selectedTags.plus(tag) 55 - } 56 - } 57 - } 45 + onSelectionComplete(selectedTags.minus(tag).plus(tag).toTypedArray()) 46 + // ^No duplicate tags^ 58 47 }, 48 + onToggleOn = { selectedTags = selectedTags.plus(tag) }, 49 + onToggleOff = { selectedTags = selectedTags.minus(tag) }, 59 50 ) 60 51 } 61 52 } ··· 75 66 private fun WithSomeTags() { 76 67 TagList( 77 68 tags = arrayOf( 78 - Tag(id = 123, name = "Sleep", isGeneral = false), 79 - Tag(id = 124, name = "Personal", isGeneral = true), 69 + Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF123456), 70 + Tag(id = 124, name = "Personal", isGeneral = true, color = 0xFF123456), 80 71 ), 81 72 ) 82 73 } ··· 86 77 private fun MultiSelectMode() { 87 78 TagList( 88 79 tags = arrayOf( 89 - Tag(id = 123, name = "Sleep", isGeneral = false), 90 - Tag(id = 124, name = "Personal", isGeneral = true), 80 + Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF123456), 81 + Tag(id = 124, name = "Personal", isGeneral = true, color = 0xFF123456), 91 82 ), 92 83 mode = TagSelectionMode.MULTI, 93 84 )
+1 -1
wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt
··· 35 35 } 36 36 } 37 37 38 - data class Tag(val id: Long, val name: String, val isGeneral: Boolean /*, val color: String */) 38 + data class Tag(val id: Long, val name: String, val isGeneral: Boolean, val color: Long) 39 39 40 40 data class Settings( 41 41 val allowMultitasking: Boolean,
+19 -11
wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediatorTest.kt
··· 6 6 package com.example.util.simpletimetracker.wearrpc 7 7 8 8 import kotlinx.coroutines.test.runTest 9 + import org.junit.Assert.assertEquals 9 10 import org.junit.Before 10 11 import org.junit.Test 11 - import org.junit.Assert.* 12 12 13 13 14 14 open class StartActivityMediatorTestBase { ··· 23 23 ) 24 24 25 25 protected val sampleActivity = Activity(id = 1, name = "Sleep", icon = "🛏️", color = 0xFF123456) 26 - protected val sampleGeneralTag = Tag(id = 13, name = "Sleep", isGeneral = true) 27 - protected val sampleNonGeneralTag = Tag(id = 14, name = "Work", isGeneral = false) 26 + protected val sampleGeneralTag = 27 + Tag(id = 13, name = "Sleep", isGeneral = true, color = 0xFF654321) 28 + protected val sampleNonGeneralTag = 29 + Tag(id = 14, name = "Work", isGeneral = false, color = 0xFF654321) 28 30 protected val settings = Settings( 29 31 allowMultitasking = false, 30 32 showRecordTagSelection = false, ··· 57 59 } 58 60 59 61 @Test 60 - fun `tag selection enabled, but not for generals alone, and activity has only general tags`() = runTest { 61 - api.mock_querySettings(settings.copy(showRecordTagSelection = true, recordTagSelectionEvenForGeneralTags = false)) 62 - api.mock_queryTagsForActivity(mapOf(sampleActivity.id to arrayOf(sampleGeneralTag))) 63 - mediator.requestStart(sampleActivity) 64 - `assert only start callback invoked`() 65 - } 62 + fun `tag selection enabled, but not for generals alone, and activity has only general tags`() = 63 + runTest { 64 + api.mock_querySettings( 65 + settings.copy( 66 + showRecordTagSelection = true, 67 + recordTagSelectionEvenForGeneralTags = false, 68 + ), 69 + ) 70 + api.mock_queryTagsForActivity(mapOf(sampleActivity.id to arrayOf(sampleGeneralTag))) 71 + mediator.requestStart(sampleActivity) 72 + `assert only start callback invoked`() 73 + } 66 74 67 75 private fun `assert only start callback invoked`() { 68 76 startCallback.assertCalledWith(sampleActivity) ··· 71 79 } 72 80 } 73 81 74 - class `Requests tags when tag selection enabled and`: StartActivityMediatorTestBase() { 82 + class `Requests tags when tag selection enabled and` : StartActivityMediatorTestBase() { 75 83 private val sampleSettings = settings.copy(showRecordTagSelection = true) 76 84 77 85 @Test ··· 97 105 } 98 106 } 99 107 100 - class MockMediatorCallback: (Activity) -> Unit { 108 + class MockMediatorCallback : (Activity) -> Unit { 101 109 private var calledWith: Activity? = null 102 110 private var callCount: Int = 0 103 111 override fun invoke(activity: Activity) {
+12 -7
wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt
··· 21 21 lateinit var messenger: Messenger 22 22 lateinit var client: WearRPCClient 23 23 24 + val tagFriends = Tag(1, "Friends", isGeneral = false, 0xFFFD3251) 25 + val tagFamily = Tag(2, "Family", isGeneral = true, 0xFFFD3251) 26 + val tagShopping = Tag(3, "Shopping", isGeneral = false, 0xFFFF0000) 27 + val tagWork = Tag(14, "Work", isGeneral = false, 0xFF00FF00) 28 + 24 29 @Before 25 30 fun setup() { 26 31 api = MockSimpleTimeTrackerAPI() ··· 101 106 CurrentActivity( 102 107 42, 103 108 jan_31_2024_afternoon, 104 - arrayOf(Tag(1, "Friends", isGeneral = false), Tag(2, "Family", isGeneral = false)), 109 + arrayOf(tagFriends, tagFamily), 105 110 ), 106 111 ) 107 112 api.mock_queryCurrentActivities(activities) ··· 117 122 CurrentActivity( 118 123 42, 119 124 jan_31_2024_afternoon, 120 - arrayOf(Tag(1, "Friends", isGeneral = false), Tag(2, "Family", isGeneral = false)), 125 + arrayOf(tagFriends, tagFamily), 121 126 ), 122 127 CurrentActivity( 123 128 42, 124 129 jan_31_2024_evening, 125 - arrayOf(Tag(5, "Shopping", isGeneral = false)), 130 + arrayOf(tagShopping), 126 131 ), 127 132 ) 128 133 api.mock_queryCurrentActivities(activities) ··· 147 152 148 153 @Test 149 154 fun returns_one_tag_associated_with_activity() = runTest { 150 - val tags = arrayOf(Tag(5, "Shopping", isGeneral = false)) 155 + val tags = arrayOf(tagShopping) 151 156 api.mock_queryTagsForActivity(mapOf(13L to tags)) 152 157 val response = client.queryTagsForActivity(13) 153 158 assertArrayEquals(tags, response) ··· 155 160 156 161 @Test 157 162 fun returns_all_tags_associated_with_activity() = runTest { 158 - val tags = arrayOf(Tag(5, "Shopping", isGeneral = false), Tag(14, "Work", isGeneral = false)) 163 + val tags = arrayOf(tagShopping, tagWork) 159 164 api.mock_queryTagsForActivity(mapOf(13L to tags)) 160 165 val response = client.queryTagsForActivity(13L) 161 166 assertArrayEquals(tags, response) ··· 163 168 164 169 @Test 165 170 fun returns_only_tags_associated_with_requested_activity() = runTest { 166 - val tags = arrayOf(Tag(5, "Shopping", isGeneral = false), Tag(14, "Work", isGeneral = false)) 167 - val otherTags = arrayOf(Tag(7, "Chores", isGeneral = false), Tag(13, "Sleep", isGeneral = false)) 171 + val tags = arrayOf(tagShopping, tagWork) 172 + val otherTags = arrayOf(tagFriends, tagFamily) 168 173 api.mock_queryTagsForActivity(mapOf(10L to tags, 17L to otherTags)) 169 174 val response = client.queryTagsForActivity(10L) 170 175 assertArrayEquals(tags, response)