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: Create MVP Activity list on WearOS

Adds a dynamic list of activities to the Wear
app's main screen which pulls directly from the
phone.

Also includes a refresh button to trigger a manual
resync without needing to reboot the Wear app.

The UI leaves a few things to be lacking,
particularly support for built-in icons instead of
just emojis and text. Additionally, pressing an
activity doesn't yet do anything.

advances thehale/SimpleTimeTracker-WearOS#3

Joseph Hale (Feb 26, 2024, 10:23 PM -0700) 3eff9add 065eb123

+87 -191
+1
wear/build.gradle.kts
··· 63 63 // Custom Dependencies 64 64 implementation("com.google.android.horologist:horologist-compose-layout:0.2.7") 65 65 implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") 66 + implementation("androidx.compose.material:material-icons-core:1.6.1") 66 67 implementation(project(":wearrpc")) 67 68 68 69 // Default Dependencies
+86 -191
wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt
··· 5 5 */ 6 6 package com.example.util.simpletimetracker.presentation 7 7 8 - import android.content.Context 9 8 import android.os.Bundle 10 - import android.util.Log 9 + import android.widget.Toast 11 10 import androidx.activity.ComponentActivity 12 11 import androidx.activity.compose.setContent 13 12 import androidx.compose.foundation.background 14 - import androidx.compose.foundation.layout.* 13 + import androidx.compose.foundation.layout.Arrangement 14 + import androidx.compose.foundation.layout.PaddingValues 15 + import androidx.compose.foundation.layout.fillMaxSize 16 + import androidx.compose.foundation.layout.fillMaxWidth 17 + import androidx.compose.foundation.layout.padding 15 18 import androidx.compose.foundation.selection.selectableGroup 19 + import androidx.compose.material.icons.Icons 20 + import androidx.compose.material.icons.rounded.Refresh 16 21 import androidx.compose.runtime.Composable 17 - import androidx.compose.ui.Alignment 22 + import androidx.compose.runtime.LaunchedEffect 23 + import androidx.compose.runtime.getValue 24 + import androidx.compose.runtime.mutableIntStateOf 25 + import androidx.compose.runtime.mutableStateOf 26 + import androidx.compose.runtime.remember 27 + import androidx.compose.runtime.setValue 18 28 import androidx.compose.ui.Modifier 19 29 import androidx.compose.ui.graphics.Color 20 30 import androidx.compose.ui.platform.LocalContext 21 - import androidx.compose.ui.res.painterResource 22 31 import androidx.compose.ui.text.style.TextOverflow 23 32 import androidx.compose.ui.unit.dp 24 - import androidx.wear.compose.material.* 25 - import com.google.android.horologist.compose.focus.rememberActiveFocusRequester 26 - import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi 27 - import com.google.android.horologist.compose.rotaryinput.* 28 - import com.example.util.simpletimetracker.R 29 - import com.example.util.simpletimetracker.data.getTimeTrackingActivities 33 + import androidx.wear.compose.material.Button 34 + import androidx.wear.compose.material.Chip 35 + import androidx.wear.compose.material.ChipDefaults 36 + import androidx.wear.compose.material.Icon 37 + import androidx.wear.compose.material.MaterialTheme 38 + import androidx.wear.compose.material.OutlinedButton 39 + import androidx.wear.compose.material.PositionIndicator 40 + import androidx.wear.compose.material.Scaffold 41 + import androidx.wear.compose.material.ScalingLazyColumn 42 + import androidx.wear.compose.material.ScalingLazyListState 43 + import androidx.wear.compose.material.Text 44 + import androidx.wear.compose.material.TimeText 45 + import androidx.wear.compose.material.Vignette 46 + import androidx.wear.compose.material.VignettePosition 47 + import androidx.wear.compose.material.rememberScalingLazyListState 48 + import androidx.wear.compose.material.scrollAway 30 49 import com.example.util.simpletimetracker.presentation.theme.SimpleTimeTrackerForWearOSTheme 50 + import com.example.util.simpletimetracker.wearrpc.Activity 31 51 import com.example.util.simpletimetracker.wearrpc.Messenger 32 52 import com.example.util.simpletimetracker.wearrpc.WearRPCClient 33 - //import com.example.util.simpletimetracker.wearrpc.CurrentActivity 34 - import kotlinx.coroutines.* 35 - //import java.util.Date 36 - 37 - const val LOG_TAG = "com.razeeman.util.simpletimetracker" 53 + import com.google.android.horologist.compose.focus.rememberActiveFocusRequester 54 + import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi 55 + import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticFeedback 56 + import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll 57 + import kotlinx.coroutines.Dispatchers 58 + import kotlinx.coroutines.async 38 59 39 60 class MainActivity : ComponentActivity() { 40 61 override fun onCreate(savedInstanceState: Bundle?) { ··· 47 68 48 69 @Composable 49 70 fun WearApp() { 50 - val context = LocalContext.current 51 - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) 52 71 SimpleTimeTrackerForWearOSTheme { 53 72 val scrollState = rememberScalingLazyListState() 54 73 Scaffold( ··· 62 81 PositionIndicator(scalingLazyListState = scrollState) 63 82 }, 64 83 ) { 65 - ScalingLazyColumn ( 66 - modifier = Modifier.fillMaxSize(), 67 - autoCentering = AutoCenteringParams(itemIndex = 0) 68 - ){ 69 - item { 70 - DebugButton( 71 - label = "Ping", 72 - onClick = { 73 - Log.i(LOG_TAG, "Pinging mobile ...") 74 - scope.async { 75 - val response = WearRPCClient(Messenger(context)).ping("Ping button") 76 - Log.i(LOG_TAG, response) 77 - } 78 - } 79 - ) 80 - } 81 - item { 82 - DebugButton(label="Query Activities", onClick = { 83 - Log.i(LOG_TAG, "Querying activities ...") 84 - scope.async { 85 - val response = WearRPCClient(Messenger(context)).queryActivities() 86 - Log.i(LOG_TAG, "Received ${response.size} activities") 87 - for (activity in response) { 88 - Log.i(LOG_TAG, " - ${activity.id}: ${activity.name}") 89 - } 90 - } 91 - }) 92 - } 93 - } 94 - 95 - // ActivityList(scrollState) 84 + ActivityList(scrollState) 96 85 } 97 86 } 98 87 } 99 88 100 - @Composable 101 - fun DebugButton(label: String, onClick: () -> Unit) { 102 - Chip( 103 - modifier = Modifier 104 - .fillMaxWidth(0.9f) 105 - .padding(top = 10.dp), 106 - label = { Text(label) }, 107 - onClick = onClick, 108 - ) 109 - } 110 - 111 - @OptIn(ExperimentalHorologistComposeLayoutApi::class) 112 - @Composable 113 - fun ScrollingColumn(scrollState: ScalingLazyListState = rememberScalingLazyListState(), content: @Composable () -> Unit) { 114 - val focusRequester = rememberActiveFocusRequester() 115 - val rotaryHapticFeedback = rememberRotaryHapticFeedback() 116 - ScalingLazyColumn( 117 - modifier = 118 - Modifier 119 - .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) 120 - .fillMaxSize() 121 - .background(MaterialTheme.colors.background) 122 - .selectableGroup(), 123 - contentPadding = PaddingValues(10.dp), 124 - verticalArrangement = Arrangement.Center, 125 - state = scrollState, 126 - ) { 127 - content 128 - } 129 - } 130 89 131 90 @OptIn(ExperimentalHorologistComposeLayoutApi::class) 132 91 @Composable 133 92 fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListState()) { 134 - val activities = getTimeTrackingActivities() 93 + val context = LocalContext.current 94 + var activities: Array<Activity> by remember { mutableStateOf(arrayOf()) } 95 + var queryCount by remember { mutableIntStateOf(0) } 96 + LaunchedEffect( 97 + key1 = queryCount, 98 + block = { 99 + async(Dispatchers.Default) { 100 + activities = WearRPCClient(Messenger(context)).queryActivities() 101 + } 102 + }, 103 + ) 104 + 135 105 val focusRequester = rememberActiveFocusRequester() 136 106 val rotaryHapticFeedback = rememberRotaryHapticFeedback() 137 107 ScalingLazyColumn( 138 - modifier = 139 - Modifier 108 + modifier = Modifier 140 109 .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) 141 110 .fillMaxSize() 142 111 .background(MaterialTheme.colors.background) ··· 146 115 state = scrollState, 147 116 ) { 148 117 for (activity in activities) { 149 - if (activity.tags.isEmpty()) { 150 - item { 151 - Activity( 152 - name = activity.name, 153 - tag = "", 154 - color = activity.color, 155 - icon = activity.iconId, 156 - ) 157 - } 158 - } else { 159 - for (tag in activity.tags) { 160 - item { 161 - Activity( 162 - name = activity.name, 163 - tag = tag, 164 - color = activity.color, 165 - icon = activity.iconId, 166 - ) 167 - } 168 - } 118 + item { 119 + Activity( 120 + id = activity.id, 121 + name = activity.name, 122 + color = Color(android.graphics.Color.parseColor(activity.color)), 123 + icon = activity.icon, 124 + ) 169 125 } 126 + } 127 + item { 128 + OutlinedButton( 129 + onClick = { 130 + queryCount++ 131 + Toast.makeText(context, "Refreshing activities", Toast.LENGTH_SHORT).show() 132 + }, 133 + content = { 134 + Icon(Icons.Rounded.Refresh, contentDescription = "Refresh Activities List") 135 + }, 136 + modifier = Modifier.padding(all = 8.dp), 137 + ) 170 138 } 171 139 } 172 140 } 173 141 174 142 @Composable 175 143 fun Activity( 144 + id: Long, 176 145 name: String, 177 - tag: String, 178 146 color: Color = Color(96, 125, 139, 255), 179 - icon: Int = R.drawable.baseline_question_mark_24, 147 + icon: String = "?", 180 148 ) { 181 - if (tag.isNotEmpty()) { 182 - ActivityWithTag(name = name, tag = tag, color = color, icon = icon) 149 + val context = LocalContext.current 150 + val briefIcon = if (icon.startsWith("ic_")) { 151 + "?" 183 152 } else { 184 - ActivityWithoutTag(name = name, color = color, icon = icon) 153 + icon.substring(0, icon.length.coerceAtMost(2)) 185 154 } 186 - } 187 - 188 - @Composable 189 - fun ActivityWithTag( 190 - name: String, 191 - tag: String, 192 - color: Color = Color(96, 125, 139, 255), 193 - icon: Int = R.drawable.baseline_question_mark_24, 194 - ) { 195 - val context = LocalContext.current 196 - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) 197 155 Chip( 198 - modifier = 199 - Modifier 156 + modifier = Modifier 200 157 .fillMaxWidth(0.9f) 201 158 .padding(top = 10.dp), 202 - icon = { 203 - ActivityIcon(iconId = icon) 204 - }, 205 159 label = { 206 160 Text( 207 - text = tag, 161 + text = "$briefIcon : $name", 208 162 maxLines = 1, 209 163 overflow = TextOverflow.Ellipsis, 210 164 ) 211 165 }, 212 - secondaryLabel = { 213 - Text( 214 - text = name, 215 - maxLines = 1, 216 - overflow = TextOverflow.Ellipsis, 217 - ) 166 + colors = ChipDefaults.chipColors( 167 + backgroundColor = color, 168 + ), 169 + onClick = { 170 + Toast.makeText( 171 + context, 172 + "Starting `$name` (id: $id)\n[Not Yet Implemented]", Toast.LENGTH_SHORT, 173 + ).show() 218 174 }, 219 - colors = 220 - ChipDefaults.chipColors( 221 - backgroundColor = color, 222 - ), 223 - onClick = { startTimeTracking(scope, context) }, 224 175 ) 225 176 } 226 177 227 - @Composable 228 - fun ActivityWithoutTag( 229 - name: String, 230 - color: Color = Color(96, 125, 139, 255), 231 - icon: Int = R.drawable.baseline_question_mark_24, 232 - ) { 233 - val context = LocalContext.current 234 - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) 235 - Chip( 236 - modifier = 237 - Modifier 238 - .fillMaxWidth(0.9f) 239 - .padding(top = 10.dp), 240 - icon = { 241 - ActivityIcon(iconId = icon) 242 - }, 243 - label = { 244 - Text( 245 - text = name, 246 - maxLines = 1, 247 - overflow = TextOverflow.Ellipsis, 248 - ) 249 - }, 250 - colors = 251 - ChipDefaults.chipColors( 252 - backgroundColor = color, 253 - ), 254 - onClick = { startTimeTracking(scope, context) }, 255 - ) 256 - } 257 - 258 - @Composable 259 - fun ActivityIcon(iconId: Int) { 260 - Icon( 261 - painter = painterResource(id = iconId), 262 - contentDescription = "activity icon", 263 - modifier = 264 - Modifier 265 - .size(ChipDefaults.IconSize) 266 - .wrapContentSize(align = Alignment.Center), 267 - ) 268 - } 269 - 270 - fun startTimeTracking(scope: CoroutineScope, context: Context) { 271 - // val activities = arrayOf( 272 - // CurrentActivity( 273 - // id, 274 - // name, 275 - // startedAt = Date(), 276 - // arrayOf(/* tags */), 277 - // ) 278 - // ) 279 - // scope.async { 280 - // WearRPCClient(context).setCurrentActivities(activities) 281 - // } 282 - }