From 828288fbacdfae6b7d6b40aab97ac779d1777595 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Tue, 13 Feb 2024 23:54:49 -0700 Subject: [PATCH 01/56] chore: Upgrade to minSDK 26 A newly scaffolded WearOS module requires these minimums, so upgrading the main app should help with compatibility. --- .../src/main/kotlin/com/example/util/simpletimetracker/Base.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/src/main/kotlin/com/example/util/simpletimetracker/Base.kt b/buildSrc/src/main/kotlin/com/example/util/simpletimetracker/Base.kt index 8a8919e0..8fa0dcc8 100644 --- a/buildSrc/src/main/kotlin/com/example/util/simpletimetracker/Base.kt +++ b/buildSrc/src/main/kotlin/com/example/util/simpletimetracker/Base.kt @@ -6,6 +6,6 @@ object Base { const val versionCode = 38 const val versionName = "1.37" - const val minSDK = 21 + const val minSDK = 26 const val currentSDK = 34 } -- 2.51.2 From 8dc895215016e4365e36a79623d4b884ecc66c54 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 3 Feb 2024 18:29:30 -0700 Subject: [PATCH 02/56] feat: Integrate thehale/SimpleTimeTracker-WearOS Incorporates the WearOS companion app developed by Joseph Hale into the origin repository for SimpleTimeTracker by razeeman. There is not yet any communication between this WearOS app and the main app, but both compile and install correctly from the same Gradle configuration! Keeping both applications in the same repository will dramatically simplify the communication between the Wear app and the main phone app. --- settings.gradle.kts | 1 + wear/.gitignore | 1 + wear/build.gradle | 71 ++++++ wear/proguard-rules.pro | 21 ++ wear/src/main/AndroidManifest.xml | 39 ++++ .../util/simpletimetracker/data/Messaging.kt | 81 +++++++ .../data/TimeTrackingActivity.kt | 77 +++++++ .../presentation/MainActivity.kt | 206 ++++++++++++++++++ .../presentation/theme/Color.kt | 26 +++ .../presentation/theme/Theme.kt | 20 ++ .../presentation/theme/Type.kt | 35 +++ .../src/main/res/drawable/baseline_bed_24.xml | 5 + .../res/drawable/baseline_check_box_24.xml | 5 + .../main/res/drawable/baseline_church_24.xml | 5 + .../res/drawable/baseline_computer_24.xml | 5 + .../drawable/baseline_directions_car_24.xml | 5 + .../res/drawable/baseline_heart_broken_24.xml | 5 + .../res/drawable/baseline_home_work_24.xml | 6 + .../drawable/baseline_horizontal_rule_24.xml | 6 + .../main/res/drawable/baseline_people_24.xml | 5 + .../drawable/baseline_question_mark_24.xml | 5 + .../main/res/drawable/baseline_timer_24.xml | 6 + .../res/drawable/ic_android_black_24dp.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes wear/src/main/res/values-round/strings.xml | 3 + wear/src/main/res/values/strings.xml | 8 + 30 files changed, 652 insertions(+) create mode 100644 wear/.gitignore create mode 100644 wear/build.gradle create mode 100644 wear/proguard-rules.pro create mode 100644 wear/src/main/AndroidManifest.xml create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/data/Messaging.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/data/TimeTrackingActivity.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Theme.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Type.kt create mode 100644 wear/src/main/res/drawable/baseline_bed_24.xml create mode 100644 wear/src/main/res/drawable/baseline_check_box_24.xml create mode 100644 wear/src/main/res/drawable/baseline_church_24.xml create mode 100644 wear/src/main/res/drawable/baseline_computer_24.xml create mode 100644 wear/src/main/res/drawable/baseline_directions_car_24.xml create mode 100644 wear/src/main/res/drawable/baseline_heart_broken_24.xml create mode 100644 wear/src/main/res/drawable/baseline_home_work_24.xml create mode 100644 wear/src/main/res/drawable/baseline_horizontal_rule_24.xml create mode 100644 wear/src/main/res/drawable/baseline_people_24.xml create mode 100644 wear/src/main/res/drawable/baseline_question_mark_24.xml create mode 100644 wear/src/main/res/drawable/baseline_timer_24.xml create mode 100644 wear/src/main/res/drawable/ic_android_black_24dp.xml create mode 100644 wear/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 wear/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 wear/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 wear/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 wear/src/main/res/values-round/strings.xml create mode 100644 wear/src/main/res/values/strings.xml diff --git a/settings.gradle.kts b/settings.gradle.kts index 3f2e7663..adb2bf60 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,3 +14,4 @@ file("features").walkTopDown().maxDepth(1).forEach { dir -> project(":${dir.name}").projectDir = dir } } +include(":wear") \ No newline at end of file diff --git a/wear/.gitignore b/wear/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/wear/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/wear/build.gradle b/wear/build.gradle new file mode 100644 index 00000000..0a2064e1 --- /dev/null +++ b/wear/build.gradle @@ -0,0 +1,71 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.example.util.simpletimetracker' + compileSdk 34 + + defaultConfig { + applicationId "com.razeeman.util.simpletimetracker" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0" + vectorDrawables { + useSupportLibrary true + } + + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + compose true + } + composeOptions { + kotlinCompilerExtensionVersion '1.4.0-alpha02' + } + packagingOptions { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +dependencies { + var compose_version = "1.3.1" + var wear_compose_version = "1.1.0" + // Custom Dependencies + implementation 'com.google.android.gms:play-services-wearable:18.0.0' + implementation 'com.google.android.horologist:horologist-compose-layout:0.2.7' + + // Default Dependencies + implementation 'androidx.core:core-ktx:1.7.0' + implementation 'com.google.android.gms:play-services-wearable:17.1.0' + implementation 'androidx.percentlayout:percentlayout:1.0.0' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation "androidx.compose.ui:ui:$compose_version" + implementation "androidx.wear.compose:compose-material:$wear_compose_version" + implementation "androidx.wear.compose:compose-foundation:$wear_compose_version" + implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' + implementation 'androidx.activity:activity-compose:1.3.1' + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" + debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" + debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" + wearApp project(":wear") +} \ No newline at end of file diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/wear/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml new file mode 100644 index 00000000..cc13b8bf --- /dev/null +++ b/wear/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/data/Messaging.kt b/wear/src/main/java/com/example/util/simpletimetracker/data/Messaging.kt new file mode 100644 index 00000000..9d4a4b72 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/data/Messaging.kt @@ -0,0 +1,81 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.data + +import android.content.Context +import android.util.Log +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.CapabilityInfo +import com.google.android.gms.wearable.Wearable +import com.example.util.simpletimetracker.presentation.LOG_TAG + +const val START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME = "start_time_tracking_activity" + +class Messaging { + fun startTimeTracking( + context: Context, + activity: String, + tag: String, + ) { + Thread( + Runnable { + startTimeTrackingTask(context, activity, tag) + }, + ).start() + } + + private fun startTimeTrackingTask( + context: Context, + activity: String, + tag: String, + ) { + // Find all nodes which support the time tracking message + val capabilityInfo: CapabilityInfo = + Tasks.await( + Wearable.getCapabilityClient(context) + .getCapability( + START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME, + CapabilityClient.FILTER_REACHABLE, + ), + ) + + // Choose the best node (the closest one connected to the watch + val nodes = capabilityInfo.nodes + val bestNode = nodes.firstOrNull { it.isNearby }?.id ?: nodes.firstOrNull()?.id + + // Send the message + val message = "$activity|$tag" + bestNode?.also { nodeId -> + Log.i(LOG_TAG, "Sending message to $bestNode") + val sendTask: Task<*> = + Wearable.getMessageClient(context).sendMessage( + nodeId, + "/$START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME", + message.toByteArray(), + ).apply { + addOnSuccessListener { + Log.i( + LOG_TAG, + "Sent $START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME message: $message", + ) + } + addOnFailureListener { + Log.e( + LOG_TAG, + "Failed to send $START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME message: $message", + ) + } + } + } ?: run { + Log.e( + LOG_TAG, + "No nodes found with the capability $START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME", + ) + } + } +} diff --git a/wear/src/main/java/com/example/util/simpletimetracker/data/TimeTrackingActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/data/TimeTrackingActivity.kt new file mode 100644 index 00000000..5d74b988 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/data/TimeTrackingActivity.kt @@ -0,0 +1,77 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.data + +import androidx.compose.ui.graphics.Color +import com.example.util.simpletimetracker.R + +class TimeTrackingActivity( + val name: String, + val tags: List, + val color: Color, + val iconId: Int, +) + +fun getTimeTrackingActivities(): List { + return listOf( + activity("TODOs", listOf("Chores", "Shopping")), + activity("Travel", listOf("Bike", "Car", "Plane", "Scooter", "Public Transit")), + activity("Planning", listOf("Goals", "Processing", "Reflection")), + activity("Personal Learning", listOf("Industry", "Hobbies", "Music")), + activity("Professional Development", listOf("Engineering", "Networking", "Trainings")), + activity("Basic Needs", listOf("Exercise", "Hygiene", "Meals", "Sleep")), + activity("Church", listOf("Service", "Study", "Worship", "Volunteer")), + activity("Activities", listOf("Family", "Friends", "Personal")), + activity("Dates"), + activity("Other"), + ) +} + +fun activity( + name: String, + tags: List = listOf(), +): TimeTrackingActivity { + return TimeTrackingActivity( + name = name, + tags = tags, + color = colorForActivity(name), + iconId = iconForActivity(name), + ) +} + +fun iconForActivity(activityName: String): Int { + val icons = + mapOf( + "Activities" to R.drawable.baseline_people_24, + "Basic Needs" to R.drawable.baseline_bed_24, + "Church" to R.drawable.baseline_church_24, + "Dates" to R.drawable.baseline_heart_broken_24, + "Other" to R.drawable.baseline_horizontal_rule_24, + "Personal Learning" to R.drawable.baseline_computer_24, + "Planning" to R.drawable.baseline_timer_24, + "Professional Development" to R.drawable.baseline_home_work_24, + "TODOs" to R.drawable.baseline_check_box_24, + "Travel" to R.drawable.baseline_directions_car_24, + ) + return icons.getOrDefault(activityName, R.drawable.baseline_question_mark_24) +} + +fun colorForActivity(activityName: String): Color { + val colors = + mapOf( + "Activities" to Color(3, 169, 244, 255), + "Basic Needs" to Color(205, 220, 57, 255), + "Church" to Color(76, 175, 80, 255), + "Dates" to Color(156, 39, 176, 255), + "Other" to Color(96, 125, 139, 255), + "Personal Learning" to Color(255, 193, 7, 255), + "Planning" to Color(255, 87, 34, 255), + "Professional Development" to Color(233, 30, 99, 255), + "TODOs" to Color(245, 54, 57, 255), + "Travel" to Color(120, 82, 72, 255), + ) + return colors.getOrDefault(activityName, Color(96, 125, 139, 255)) +} diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt new file mode 100644 index 00000000..31944722 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -0,0 +1,206 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation + +import android.content.Context +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.* +import com.google.android.horologist.compose.focus.rememberActiveFocusRequester +import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi +import com.google.android.horologist.compose.rotaryinput.* +import com.example.util.simpletimetracker.R +import com.example.util.simpletimetracker.data.Messaging +import com.example.util.simpletimetracker.data.getTimeTrackingActivities +import com.example.util.simpletimetracker.presentation.theme.SimpleTimeTrackerForWearOSTheme + +const val LOG_TAG = "com.razeeman.util.simpletimetracker" + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + WearApp() + } + } +} + +@Composable +fun WearApp() { + SimpleTimeTrackerForWearOSTheme { + val scrollState = rememberScalingLazyListState() + Scaffold( + timeText = { + TimeText(modifier = Modifier.scrollAway(scrollState)) + }, + vignette = { + Vignette(vignettePosition = VignettePosition.TopAndBottom) + }, + positionIndicator = { + PositionIndicator(scalingLazyListState = scrollState) + }, + ) { + ActivityList(scrollState) + } + } +} + +@OptIn(ExperimentalHorologistComposeLayoutApi::class) +@Composable +fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListState()) { + val activities = getTimeTrackingActivities() + val focusRequester = rememberActiveFocusRequester() + val rotaryHapticFeedback = rememberRotaryHapticFeedback() + ScalingLazyColumn( + modifier = + Modifier + .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) + .fillMaxSize() + .background(MaterialTheme.colors.background) + .selectableGroup(), + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.Center, + state = scrollState, + ) { + for (activity in activities) { + if (activity.tags.isEmpty()) { + item { + Activity( + name = activity.name, + tag = "", + color = activity.color, + icon = activity.iconId, + ) + } + } else { + for (tag in activity.tags) { + item { + Activity( + name = activity.name, + tag = tag, + color = activity.color, + icon = activity.iconId, + ) + } + } + } + } + } +} + +@Composable +fun Activity( + name: String, + tag: String, + color: Color = Color(96, 125, 139, 255), + icon: Int = R.drawable.baseline_question_mark_24, +) { + if (tag.isNotEmpty()) { + ActivityWithTag(name = name, tag = tag, color = color, icon = icon) + } else { + ActivityWithoutTag(name = name, color = color, icon = icon) + } +} + +@Composable +fun ActivityWithTag( + name: String, + tag: String, + color: Color = Color(96, 125, 139, 255), + icon: Int = R.drawable.baseline_question_mark_24, +) { + val context = LocalContext.current + Chip( + modifier = + Modifier + .fillMaxWidth(0.9f) + .padding(top = 10.dp), + icon = { + ActivityIcon(iconId = icon) + }, + label = { + Text( + text = tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + secondaryLabel = { + Text( + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + colors = + ChipDefaults.chipColors( + backgroundColor = color, + ), + onClick = { startTimeTracking(context, name, tag) }, + ) +} + +@Composable +fun ActivityWithoutTag( + name: String, + color: Color = Color(96, 125, 139, 255), + icon: Int = R.drawable.baseline_question_mark_24, +) { + val context = LocalContext.current + Chip( + modifier = + Modifier + .fillMaxWidth(0.9f) + .padding(top = 10.dp), + icon = { + ActivityIcon(iconId = icon) + }, + label = { + Text( + text = name, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + colors = + ChipDefaults.chipColors( + backgroundColor = color, + ), + onClick = { startTimeTracking(context, name, "") }, + ) +} + +@Composable +fun ActivityIcon(iconId: Int) { + Icon( + painter = painterResource(id = iconId), + contentDescription = "activity icon", + modifier = + Modifier + .size(ChipDefaults.IconSize) + .wrapContentSize(align = Alignment.Center), + ) +} + +fun startTimeTracking( + context: Context, + activity: String, + tag: String, +) { + Messaging().startTimeTracking(context, activity, tag) +} diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt new file mode 100644 index 00000000..04707998 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt @@ -0,0 +1,26 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.theme + +import androidx.compose.ui.graphics.Color +import androidx.wear.compose.material.Colors + +val Purple200 = Color(0xFFBB86FC) +val Purple700 = Color(0xFF3700B3) +val Teal200 = Color(0xFF03DAC5) +val Red400 = Color(0xFFCF6679) + +internal val wearColorPalette: Colors = + Colors( + primary = Purple200, + primaryVariant = Purple700, + secondary = Teal200, + secondaryVariant = Teal200, + error = Red400, + onPrimary = Color.Black, + onSecondary = Color.Black, + onError = Color.Black, + ) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Theme.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Theme.kt new file mode 100644 index 00000000..a9626fac --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Theme.kt @@ -0,0 +1,20 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.theme + +import androidx.compose.runtime.Composable +import androidx.wear.compose.material.MaterialTheme + +@Composable +fun SimpleTimeTrackerForWearOSTheme(content: @Composable () -> Unit) { + MaterialTheme( + colors = wearColorPalette, + typography = Typography, + // For shapes, we generally recommend using the default Material Wear shapes which are + // optimized for round and non-round devices. + content = content, + ) +} diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Type.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Type.kt new file mode 100644 index 00000000..f6cad0d1 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Type.kt @@ -0,0 +1,35 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.theme + +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import androidx.wear.compose.material.Typography + +// Set of Material typography styles to start with +val Typography = + Typography( + body1 = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + ), + /* Other default text styles to override + button = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.W500, + fontSize = 14.sp + ), + caption = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp + ) + */ + ) diff --git a/wear/src/main/res/drawable/baseline_bed_24.xml b/wear/src/main/res/drawable/baseline_bed_24.xml new file mode 100644 index 00000000..899b686c --- /dev/null +++ b/wear/src/main/res/drawable/baseline_bed_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/wear/src/main/res/drawable/baseline_check_box_24.xml b/wear/src/main/res/drawable/baseline_check_box_24.xml new file mode 100644 index 00000000..2a4d0943 --- /dev/null +++ b/wear/src/main/res/drawable/baseline_check_box_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/wear/src/main/res/drawable/baseline_church_24.xml b/wear/src/main/res/drawable/baseline_church_24.xml new file mode 100644 index 00000000..4e08ef4c --- /dev/null +++ b/wear/src/main/res/drawable/baseline_church_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/wear/src/main/res/drawable/baseline_computer_24.xml b/wear/src/main/res/drawable/baseline_computer_24.xml new file mode 100644 index 00000000..e03e8606 --- /dev/null +++ b/wear/src/main/res/drawable/baseline_computer_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/wear/src/main/res/drawable/baseline_directions_car_24.xml b/wear/src/main/res/drawable/baseline_directions_car_24.xml new file mode 100644 index 00000000..a3e0f855 --- /dev/null +++ b/wear/src/main/res/drawable/baseline_directions_car_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/wear/src/main/res/drawable/baseline_heart_broken_24.xml b/wear/src/main/res/drawable/baseline_heart_broken_24.xml new file mode 100644 index 00000000..6e78d0e5 --- /dev/null +++ b/wear/src/main/res/drawable/baseline_heart_broken_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/wear/src/main/res/drawable/baseline_home_work_24.xml b/wear/src/main/res/drawable/baseline_home_work_24.xml new file mode 100644 index 00000000..9607b861 --- /dev/null +++ b/wear/src/main/res/drawable/baseline_home_work_24.xml @@ -0,0 +1,6 @@ + + + + diff --git a/wear/src/main/res/drawable/baseline_horizontal_rule_24.xml b/wear/src/main/res/drawable/baseline_horizontal_rule_24.xml new file mode 100644 index 00000000..49851abd --- /dev/null +++ b/wear/src/main/res/drawable/baseline_horizontal_rule_24.xml @@ -0,0 +1,6 @@ + + + diff --git a/wear/src/main/res/drawable/baseline_people_24.xml b/wear/src/main/res/drawable/baseline_people_24.xml new file mode 100644 index 00000000..a3ccd711 --- /dev/null +++ b/wear/src/main/res/drawable/baseline_people_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/wear/src/main/res/drawable/baseline_question_mark_24.xml b/wear/src/main/res/drawable/baseline_question_mark_24.xml new file mode 100644 index 00000000..1d4bf082 --- /dev/null +++ b/wear/src/main/res/drawable/baseline_question_mark_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/wear/src/main/res/drawable/baseline_timer_24.xml b/wear/src/main/res/drawable/baseline_timer_24.xml new file mode 100644 index 00000000..7732df44 --- /dev/null +++ b/wear/src/main/res/drawable/baseline_timer_24.xml @@ -0,0 +1,6 @@ + + + + diff --git a/wear/src/main/res/drawable/ic_android_black_24dp.xml b/wear/src/main/res/drawable/ic_android_black_24dp.xml new file mode 100644 index 00000000..fe512307 --- /dev/null +++ b/wear/src/main/res/drawable/ic_android_black_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/wear/src/main/res/mipmap-hdpi/ic_launcher.webp b/wear/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 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

( 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 literal 0 HcmV?d00001 diff --git a/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..aa7d6427e6fa1074b79ccd52ef67ac15c5637e85 GIT binary patch literal 3844 zcmWIYbaVT|$G{No>J$(bU=hK^z`!8Dz`(E{j2wLeJldHU7(>b%bUDJ3u)$aAX7!}(6$UFx zSAJ(;QR8K7Q)4t%i+=x8TwYyK?P*%phS&RMFPd4^=k>ODyUiUb+w~U`j$i(`{EcNx zX0h4Z?B#Y{6Nv6&-#EViZ?%=zb_mtm-8m%igMtLg>q%5EcXXQvfLM45im!z zY(`Of&gO=bPjM`(1aJ!@ERx-wv2=J6*7Qs!s4Ev)(YIAf+&bNI{FtF51YU75I5 z(fL)Qt&a{LtBt``nY-P&6-8H`nTfee$JbPQb-m&X%F(l5Riw=$-{<|jZ%wbHL+;lb z*6bJ7YS=tzGc11H^&)OU!{_jKC)GAdT;V&;d|qdfM;ZHx%Nw3;+4?*DLlTFex$W*f zzf`5BHmc;iEi2w{QPL{P9Je#;%&H&3sVYSc-e);%O8(A?74wskzEb=pe}C2Qi{;nX zA3ZOcc_OGNT)O=_zx}U=Cpf;z*!(pqe*Ej<{w)*J&bYpt#LxfZ=i>FkRdI!f&Isnz z8(!);arN#~p_~~L-&pT_<#MS==!vJ$422cXIf4Z|*pxSkK6y7OpiAX!&xwep)3TT3 zZzTU><}?G|1tc; zUa6ju_Wer#t9@_(mDV5rs{7CR3iDUzKkuK0XPJAOwzZ~e{s8}&E7CjUQJ&HY<SKKvfO^VCzn)BnFqJ08klIC19l-HvVN zpM?F^sku~cr)z1v!81)nS()_(pW}`*=9!a1QorARTqkZA-X8j9YwJq6LvLeM*B2l8 zykVxX&>EE%F^?B-ULQ6Uu83EZe=g@{;{8R<(Z%$afR1d{!g5b|H(~LryO|DtHt$i*Jp!Yu7CejS27u&h}|Nnnt^m()?X0BW3$>8FpLR0E4`fr*xYyYh2 zo~|;Ai+`2e&R67=uj5uU*Im24W6zVRMy$D#6B-!)eR#>hz#x48{}uUoJ*{XFroj83 zuIL3Fc`otwut8|9=6lY1v11vdv`KF~?!yPSv*m+|PD>zHx&2)!qFcWL2^cglSw@ zIluXVfpX}s!1#GOi{k!DEPe5P@g|k%$KT{XDSIAq?by9Qz_f1PJ>Q;rK^;}!+xF

0vl^(ubK57i3$j`RXVdItc2Zu zTjP%3T{qqDyHu$1wf>en`}f$lI?l)hfy+`aCTxu8`Mp4Wt8~lzsk7fIKUzdbU&D@u>@#|8JoKt2mbv8|~*&2E%ct_*EIJ>)7o&PKE5_$1H zYTdgu^~m!0A4{%YZ`c&qZ)$yr|AXm!sqXjcKR?_Pn|Ao^jNS9)1$Q|wns@3F%fsvg zwSO(SHf}Wd&gY@5dZETG=E3!B4m$1-}j)^|n6tq;mOd3k8c0DH_GHdj+bC#}?wC!#j7mQS#vf_fXiT$x+$0-Ud(~5MTubmb>WqqsT;k{xpsIQhc5wmzv^leMXZ>rUpkvod!E z%4X!{CT@DfUb}1l9;OF%&cZj$=~`?izExW$`!W#MmrU&bU9x zY4?Y|ihvD`aVe?!Jd*bn>a_Ljw%A+cxQM63#5tgD>7pvIfk zXZB`A(+B?|xK;mhgAxy6j8m5a|tYYQtz-&)`wciH>w!z4Jl6EbdLNgW&t4n&Z^`r` zg&vWF3I>a?I;mM9pQdx?h+g`4^#9q@#m5&cUv`wUW67(N(f8cqnde#^*{|z4`#8&% z$(*MTuC#KwaPVof`>D`&U$yg@H74@YWB+XNzOk9HzHI)?S*96(9jadGnl+RhV?HYU zTPymOU!2^Y9Y;SYsn1QEpStG70rM^O4*D71J+(bX{#n9KLDg&XW+cq#d%E33PUWv_ zkfOTshO5hBt#1C8*W^yL{vnXQVRqiLErQMrj6U1<_5@j|Em*A4E4-te^ZzpYDI0B` zXg;x>w9{g(u&mK%{^Fz8n{xBlOZuI;!E!86S+BwIO2Xl&Fh%#ZoBUQU_O0g4%ANk> z0Jmq=?VG;}VuQcWs7ieBJAdXY(fhi`7ADvReRQ>AUivA=PwM}&`iLJJJJZ{_&k3Y2 zUvhnu->Tx7bEg!!9%6moQp;o7lU20XJ=QDEK<4}Dx`6F-nzAlROmUdnwpX%JBgyHE&EJd_^1F8lZE|Tb`4Z9o=_5<>!KzQ} zGZoDj*B>l-_{cMi-TF`Qmi1X-=QLK8d3<{luV|^Id8CphQgh85osBxngzXY9@84_h z%f`4XHqy%Sk8jJOIRCXp`#HYZ9tjb8r7sFp_47S~Y8LMZ0|ACD*T9*?vNtsq>c#FZ;4AF83!N-}7Zh7R+l= zw!4tMaFcX!P3Els8SK9LLhRyJK z+&t%0aQM50`k&8S%~Uwp?rm21|LFNWlV+G(ACydR-+6qUWz*kv3iZ{C%If{&>euW) z@@W5$w|~FeitEW*v$L+4y5z_58%Bq(G3{FX-GAnci|2Se?q;?>GnLW&F(+=#|9Z#w z-#_o;-&VrrqOwwO%B-^WHLF&}EjZj>w|%nOHqFIdHlY~LM|@kt^cB?v?aRN!JSJaHjLeW zb$GNz$NM?U8z(oNo3`r8e%lS3CEre$zVUyZ&{wym*PqV*QMTjIf~G4kJUQ0C+9_cC zLSfq{(=7&@u0G04(^)DtZ};(To{cq(j|-+htN*jEpuxFk0?Ru8XP3^z-&SNkURt^2 zTSfZcN5x^>ML|ky1rH}oViJ7+@cetDleV!hBksSwEn~4q@Xz`GmCrjHOK)GEd0p-B z(Y6SS8poDMuE(=E|4rGmcvp9MsGHU9V8)Ek>HE*irk%F9rK7RU-2N}yiz$uUO57Qx zXRAv!tdNVQd!7^~xNg;>=ojmFGC8tun)m$oP3PKhc*onS@K5?rr^}yu zUmUrRUESJJ=>F0}m;TMP>i;SjwwR~)_nQ*mc_z*-WwSXs6&fs_bqPz9s|2|0xg0$2 t@u$Pda=rYAtrOikX7%&GRsG&;Z1Lh{&yL;Ao6ROYUN$pxVgmyL003>zdZqvX literal 0 HcmV?d00001 diff --git a/wear/src/main/res/values-round/strings.xml b/wear/src/main/res/values-round/strings.xml new file mode 100644 index 00000000..42f12297 --- /dev/null +++ b/wear/src/main/res/values-round/strings.xml @@ -0,0 +1,3 @@ + + From the Round world,\nHello, %1$s! + \ No newline at end of file diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml new file mode 100644 index 00000000..0f0b2ec6 --- /dev/null +++ b/wear/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + Simple Time Tracker + + From the Square world,\nHello, %1$s! + \ No newline at end of file -- 2.51.2 From 8a482993e59714d7c0ca3250d4b7b535a05d94d6 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 10 Feb 2024 00:23:30 -0700 Subject: [PATCH 03/56] feat: Create interface for Wear <-> Mobile communication Defines the `SimpleTimeTrackerAPI`, an interface outlining the methods required for building a useful WearOS application. Also provides a WearRPCClient and WearRPCServer as a useful wrapper around the communication plumbing for WearOS <-> Mobile. --- settings.gradle.kts | 3 +- wearrpc/.gitignore | 1 + wearrpc/build.gradle.kts | 48 +++++++++ wearrpc/consumer-rules.pro | 0 wearrpc/proguard-rules.pro | 21 ++++ .../wearrpc/ExampleInstrumentedTest.kt | 24 +++++ wearrpc/src/main/AndroidManifest.xml | 4 + .../util/simpletimetracker/wearrpc/DTO.kt | 39 +++++++ .../simpletimetracker/wearrpc/Messenger.kt | 62 +++++++++++ .../util/simpletimetracker/wearrpc/Request.kt | 9 ++ .../wearrpc/SimpleTimeTrackerAPI.kt | 23 ++++ .../wearrpc/WearRPCClient.kt | 41 +++++++ .../wearrpc/WearRPCException.kt | 3 + .../wearrpc/WearRPCServer.kt | 45 ++++++++ .../wearrpc/WearRPCServerTest.kt | 102 ++++++++++++++++++ 15 files changed, 424 insertions(+), 1 deletion(-) create mode 100644 wearrpc/.gitignore create mode 100644 wearrpc/build.gradle.kts create mode 100644 wearrpc/consumer-rules.pro create mode 100644 wearrpc/proguard-rules.pro create mode 100644 wearrpc/src/androidTest/java/com/example/util/simpletimetracker/wearrpc/ExampleInstrumentedTest.kt create mode 100644 wearrpc/src/main/AndroidManifest.xml create mode 100644 wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt create mode 100644 wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt create mode 100644 wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt create mode 100644 wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt create mode 100644 wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt create mode 100644 wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCException.kt create mode 100644 wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt create mode 100644 wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt diff --git a/settings.gradle.kts b/settings.gradle.kts index adb2bf60..61d4042a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,4 +14,5 @@ file("features").walkTopDown().maxDepth(1).forEach { dir -> project(":${dir.name}").projectDir = dir } } -include(":wear") \ No newline at end of file +include(":wear") +include(":wearrpc") diff --git a/wearrpc/.gitignore b/wearrpc/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/wearrpc/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/wearrpc/build.gradle.kts b/wearrpc/build.gradle.kts new file mode 100644 index 00000000..a2689fcf --- /dev/null +++ b/wearrpc/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") +} + +android { + namespace = "com.example.util.simpletimetracker.wearrpc" + compileSdk = 34 + + defaultConfig { + minSdk = 26 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + // Custom Dependencies + implementation("com.google.android.gms:play-services-wearable:18.0.0") + implementation("com.google.code.gson:gson:2.10.1") + + // Auto-generated dependencies + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.11.0") + testImplementation("junit:junit:4.13.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") +} \ No newline at end of file diff --git a/wearrpc/consumer-rules.pro b/wearrpc/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/wearrpc/proguard-rules.pro b/wearrpc/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/wearrpc/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/wearrpc/src/androidTest/java/com/example/util/simpletimetracker/wearrpc/ExampleInstrumentedTest.kt b/wearrpc/src/androidTest/java/com/example/util/simpletimetracker/wearrpc/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..a19b76ab --- /dev/null +++ b/wearrpc/src/androidTest/java/com/example/util/simpletimetracker/wearrpc/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.example.util.simpletimetracker.wearrpc + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.example.util.simpletimetracker.wearrpc.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/wearrpc/src/main/AndroidManifest.xml b/wearrpc/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/wearrpc/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt new file mode 100644 index 00000000..1a13aa77 --- /dev/null +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt @@ -0,0 +1,39 @@ +package com.example.util.simpletimetracker.wearrpc + +/** + * Data Transfer Objects + * + * Object definitions for records sent between Wear/Mobile + */ + +import java.util.Date + +data class Activity(val id: Long, val name: String /* , val color: String, val icon: ByteArray */) + +data class CurrentActivity(val id: Long, val name: String, val startedAt: Date, val tags: Array) { + override fun equals(other: Any?): Boolean { + // autogenerated + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as CurrentActivity + + if (id != other.id) return false + if (name != other.name) return false + if (startedAt != other.startedAt) return false + return tags.contentEquals(other.tags) + } + + override fun hashCode(): Int { + // autogenerated + var result = id.toInt() + result = 31 * result + name.hashCode() + result = 31 * result + startedAt.hashCode() + result = 31 * result + tags.contentHashCode() + return result + } +} + +data class Tag(val id: Long, val name: String /*, val color: String */) + +data class Settings(val multitasking: Boolean) \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt new file mode 100644 index 00000000..022777a8 --- /dev/null +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt @@ -0,0 +1,62 @@ +package com.example.util.simpletimetracker.wearrpc + +import android.content.Context +import android.util.Log +import com.google.android.gms.tasks.Tasks +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.CapabilityInfo +import com.google.android.gms.wearable.Node +import com.google.android.gms.wearable.Wearable +import kotlinx.coroutines.CompletableDeferred +import java.util.concurrent.CancellationException + +class Messenger(private val context: Context) { + private val TAG: String = Messenger::class.java.name + + suspend fun sendMessage(capability: String): ByteArray? { + return sendMessage(capability, ByteArray(0)) + } + + suspend fun sendMessage(capability: String, message: ByteArray): ByteArray? { + val def = CompletableDeferred() + val bestNode = findNearestNode(capability) + + // Send the message + bestNode?.also { nodeId -> + Log.d(TAG, "Sending message to ${bestNode?.displayName}") + Wearable.getMessageClient(context).sendRequest(bestNode.id, capability, message) + .addOnSuccessListener { + Log.d(TAG, "Response received for $capability") + def.complete(it) + }.addOnCanceledListener { + val message = "Request $capability to ${bestNode.displayName} was cancelled" + Log.d(TAG, message) + def.cancel(CancellationException(message)) + }.addOnFailureListener { + val message = + "No response received from mobile for $capability : ${String(message)}" + Log.d(TAG, message) + def.cancel(CancellationException(message)) + } + } ?: run { + val message = "No nodes found with the capability $capability" + Log.d(TAG, message) + def.cancel(CancellationException(message)) + } + return def.await() + } + + private suspend fun findNearestNode(capability: String): Node? { + // Find all nodes which support the time tracking message + Log.d(TAG, "Searching for nodes with Simple Time Tracker installed") + val capabilityInfo: CapabilityInfo = Tasks.await( + Wearable.getCapabilityClient(context) + .getCapability(capability, CapabilityClient.FILTER_REACHABLE), + ) + + // Choose the best node (the closest one connected to the watch) + val nodes = capabilityInfo.nodes + Log.d(TAG, nodes.toString()) + return nodes.firstOrNull { it.isNearby } ?: nodes.firstOrNull() + } +} \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt new file mode 100644 index 00000000..18609a22 --- /dev/null +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt @@ -0,0 +1,9 @@ +package com.example.util.simpletimetracker.wearrpc + +object Request { + val PING = "/stt//GET/ping" + val GET_ACTIVITIES = "/stt//GET/activities" + val GET_CURRENT_ACTIVITIES = "/stt//GET/activities/current" + val START_ACTIVITIES = "/stt//POST/activities/current" + val GET_TAGS_FOR_ACTIVITY = "/stt//GET/activities/:ID/tags" +} \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt new file mode 100644 index 00000000..c2384305 --- /dev/null +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt @@ -0,0 +1,23 @@ +package com.example.util.simpletimetracker.wearrpc + +interface SimpleTimeTrackerAPI { + /** /stt//GET/ping */ + suspend fun ping(str: String): String { + return str + } + + /** /stt//GET/activities */ + suspend fun queryActivities(): Array + + /** /stt//GET/activities/current */ + suspend fun queryCurrentActivities(): Array + + /** /stt//POST/activities/current */ + suspend fun setCurrentActivities(activities: Array): Unit + + /** /stt//GET/activities/:ID/tags */ + suspend fun queryTagsForActivity(activityId: Long): Array + + /** /stt//GET/settings */ + suspend fun querySettings(): Settings +} diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt new file mode 100644 index 00000000..214afd3f --- /dev/null +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt @@ -0,0 +1,41 @@ +package com.example.util.simpletimetracker.wearrpc + +import android.util.Log +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + + +class WearRPCClient(private val messenger: Messenger): SimpleTimeTrackerAPI { + private val TAG: String = WearRPCClient::class.java.name + + override suspend fun ping(message: String): String { + val response = messenger.sendMessage(Request.PING, message.toByteArray()) + if (response != null) return String(response) + else throw WearRPCException("No response") + } + + override suspend fun queryActivities(): Array { + val response = messenger.sendMessage(Request.GET_ACTIVITIES) + if (response != null) { + Log.i(TAG, String(response)) + val collectionType = object : TypeToken>() {}.type + return Gson().fromJson(String(response), collectionType) + } else throw WearRPCException("No response") + } + + override suspend fun queryCurrentActivities(): Array { + TODO("Not yet implemented") + } + + override suspend fun setCurrentActivities(activities: Array) { + TODO("Not yet implemented") + } + + override suspend fun queryTagsForActivity(activityId: Long): Array { + TODO("Not yet implemented") + } + + override suspend fun querySettings(): Settings { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCException.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCException.kt new file mode 100644 index 00000000..a45b160e --- /dev/null +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCException.kt @@ -0,0 +1,3 @@ +package com.example.util.simpletimetracker.wearrpc + +class WearRPCException(message: String) : Exception(message) {} \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt new file mode 100644 index 00000000..f1fa4313 --- /dev/null +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.wearrpc + +import com.google.gson.Gson + + +class WearRPCServer(private val api: SimpleTimeTrackerAPI) { + + suspend fun onRequest(path: String, request: ByteArray): ByteArray? { + if (path.startsWith("/stt")) { + when (path) { + Request.PING -> return onPing(request) + Request.GET_ACTIVITIES -> return onGetActivities() + Request.GET_CURRENT_ACTIVITIES -> return onGetCurrentActivities() + Request.START_ACTIVITIES -> return onStartActivities(request) + Request.GET_TAGS_FOR_ACTIVITY -> return onGetTagsForActivity(request) + else -> throw WearRPCException("$path is an invalid RPC call") + } + } else return null + } + + private suspend fun onGetTagsForActivity(request: ByteArray): ByteArray? { + TODO("Not yet implemented") + } + + private suspend fun onStartActivities(request: ByteArray): ByteArray? { + TODO("Not yet implemented") + } + + private suspend fun onPing(request: ByteArray): ByteArray? { + return request + } + + private suspend fun onGetActivities(): ByteArray? { + return Gson().toJson(api.queryActivities()).toByteArray() + } + + private suspend fun onGetCurrentActivities(): ByteArray? { + return Gson().toJson(api.queryCurrentActivities()).toByteArray() + } +} \ No newline at end of file diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt new file mode 100644 index 00000000..b2276d4a --- /dev/null +++ b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt @@ -0,0 +1,102 @@ +package com.example.util.simpletimetracker.wearrpc + +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before +import kotlinx.coroutines.test.runTest + + +open class WearRPCServerTestBase { + lateinit var api: MockSimpleTimeTrackerAPI + lateinit var rpc: WearRPCServer + + + @Before fun setup() { + api = MockSimpleTimeTrackerAPI() + rpc = WearRPCServer(api) + api.mockReset() + } + + fun assertResponseEquals(expected: String, response: ByteArray?) { + if (response != null) assertEquals(expected, String(response)) + else fail("response was unexpectedly null") + } +} + +class WearRPCServerTest: WearRPCServerTestBase() { + @Test fun returns_null_for_unsupported_request() = runTest { + val response = rpc.onRequest("/fake/path", "fake data".toByteArray()) + assertNull(response) + } + + @Test(expected = WearRPCException::class) + fun raises_for_invalid_SimpleTimeTracker_request() = runTest { + rpc.onRequest("/stt//GET/fake/path", "fake data".toByteArray()) + } +} + +class PingEndpointTest: WearRPCServerTestBase() { + + @Test fun responds_to_empty_request_with_empty_string() = runTest { + val response = rpc.onRequest(Request.PING, ByteArray(0)) + assertResponseEquals("", response) + } + + @Test fun echoes_response() = runTest { + val response = rpc.onRequest(Request.PING, "Hello World!".toByteArray()) + assertResponseEquals("Hello World!", response) + } +} + +class GetActivitiesTest: WearRPCServerTestBase() { + @Test fun returns_no_activities_when_none_are_available() = runTest { + val response = rpc.onRequest(Request.GET_ACTIVITIES, ByteArray(0)) + assertResponseEquals("[]", response) + } + + @Test fun returns_one_activity_when_one_exists() = runTest { + api.mock_queryActivities(arrayOf(Activity(42, "Chores"))) + val response = rpc.onRequest(Request.GET_ACTIVITIES, ByteArray(0)) + assertResponseEquals("[{\"id\":42,\"name\":\"Chores\"}]", response) + } + + @Test fun returns_all_existing_activities() = runTest { + api.mock_queryActivities(arrayOf(Activity(13, "Singing"), Activity(24, "Homework"))) + val response = rpc.onRequest(Request.GET_ACTIVITIES, ByteArray(0)) + assertResponseEquals("[{\"id\":13,\"name\":\"Singing\"},{\"id\":24,\"name\":\"Homework\"}]", response) + } +} + + +class MockSimpleTimeTrackerAPI: SimpleTimeTrackerAPI { + + lateinit var activities: Array + override suspend fun queryActivities(): Array { + return activities + } + + fun mock_queryActivities(activities: Array) { + this.activities = activities + } + + override suspend fun queryCurrentActivities(): Array { + TODO("Not yet implemented") + } + + override suspend fun setCurrentActivities(activities: Array): Unit { + TODO("Not yet implemented") + } + + override suspend fun queryTagsForActivity(activityId: Long): Array { + TODO("Not yet implemented") + } + + override suspend fun querySettings(): Settings { + TODO("Not yet implemented") + } + + fun mockReset() { + this.activities = arrayOf() + } + +} \ No newline at end of file -- 2.51.2 From a983e14517662992f3c9da3e677d583a463b3b7e Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 10 Feb 2024 00:28:49 -0700 Subject: [PATCH 04/56] feat: Create WearRPCServer on Mobile Implemented as a WearableListenerService so that messages can be received and responded to whether or not the app is actively running. --- app/build.gradle.kts | 3 ++ app/src/main/AndroidManifest.xml | 9 ++++ .../util/simpletimetracker/wear/DomainAPI.kt | 43 +++++++++++++++ .../simpletimetracker/wear/WearService.kt | 52 +++++++++++++++++++ app/src/main/res/values/wear.xml | 12 +++++ 5 files changed, 119 insertions(+) create mode 100644 app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt create mode 100644 app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt create mode 100644 app/src/main/res/values/wear.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b948dda7..6e9ca6c9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -97,7 +97,10 @@ dependencies { implementation(project(":feature_data_edit")) implementation(project(":feature_records_filter")) implementation(project(":feature_goals")) + implementation(project(":wearrpc")) + implementation("com.google.android.gms:play-services-wearable:18.0.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") implementation(Deps.Androidx.room) implementation(Deps.Ktx.navigationFragment) implementation(Deps.Ktx.navigationUi) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1d64b6a0..a91a129a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -45,6 +45,15 @@ android:value="true" /> + + + + + + diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt new file mode 100644 index 00000000..b0f12f3d --- /dev/null +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -0,0 +1,43 @@ +package com.example.util.simpletimetracker.wear + +import com.example.util.simpletimetracker.domain.interactor.PrefsInteractor +import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor +import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor +import com.example.util.simpletimetracker.domain.mapper.AppColorMapper +import com.example.util.simpletimetracker.wearrpc.Activity +import com.example.util.simpletimetracker.wearrpc.CurrentActivity +import com.example.util.simpletimetracker.wearrpc.Settings +import com.example.util.simpletimetracker.wearrpc.SimpleTimeTrackerAPI +import com.example.util.simpletimetracker.wearrpc.Tag + +class DomainAPI ( + private val prefsInteractor: PrefsInteractor, + private val recordTypeInteractor: RecordTypeInteractor, + private val runningRecordInteractor: RunningRecordInteractor, + private val appColorMapper: AppColorMapper, +): SimpleTimeTrackerAPI { + + override suspend fun queryActivities(): Array { + return recordTypeInteractor.getAll() + .filter { recordType -> !recordType.hidden } + .map { recordType -> Activity(recordType.id, recordType.name) } + .toTypedArray() + } + + override suspend fun queryCurrentActivities(): Array { + TODO("Not yet implemented") + } + + override suspend fun setCurrentActivities(activities: Array) { + TODO("Not yet implemented") + } + + override suspend fun queryTagsForActivity(activityId: Long): Array { + TODO("Not yet implemented") + } + + override suspend fun querySettings(): Settings { + TODO("Not yet implemented") + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt new file mode 100644 index 00000000..6ffc92e5 --- /dev/null +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt @@ -0,0 +1,52 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.wear + +import com.example.util.simpletimetracker.domain.interactor.PrefsInteractor +import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor +import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor +import com.example.util.simpletimetracker.domain.mapper.AppColorMapper +import com.example.util.simpletimetracker.wearrpc.WearRPCServer +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.android.gms.wearable.WearableListenerService +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +/** + * Landing point for messages coming to Mobile from Wear. + * + * Implemented as a Service so that it can receive messages even when the Mobile app is stopped or + * in the background. + */ +@AndroidEntryPoint +class WearService : WearableListenerService() { + + @Inject + lateinit var prefsInteractor: PrefsInteractor + + @Inject + lateinit var recordTypeInteractor: RecordTypeInteractor + + @Inject + lateinit var runningRecordInteractor: RunningRecordInteractor + + @Inject + lateinit var appColorMapper: AppColorMapper + + override fun onRequest(nodeId: String, path: String, request: ByteArray): Task? { + val rpc = WearRPCServer( + DomainAPI( + prefsInteractor, + recordTypeInteractor, + runningRecordInteractor, + appColorMapper, + ), + ) + return runBlocking { Tasks.forResult(rpc.onRequest(path, request)) } + } +} diff --git a/app/src/main/res/values/wear.xml b/app/src/main/res/values/wear.xml new file mode 100644 index 00000000..19fe4bcd --- /dev/null +++ b/app/src/main/res/values/wear.xml @@ -0,0 +1,12 @@ + + + + /stt//GET/ping + /stt//GET/activities + /stt//GET/activities/current + /stt//POST/activities/current + /stt//GET/activites/:ID/tags + /stt//GET/settings + + \ No newline at end of file -- 2.51.2 From 1bd90d4a685679cd0232d41b62f5a0fd09ba5872 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 10 Feb 2024 00:37:09 -0700 Subject: [PATCH 05/56] feat: Create WearRPCClient in WearOS Creates an MVP driver for querying live data from the Mobile phone. The previous UI has been hidden, though not deleted since much of the code will likely be reused in the new implementation. --- wear/build.gradle | 71 ---------- wear/build.gradle.kts | 77 +++++++++++ wear/proguard-rules.pro | 2 +- .../util/simpletimetracker/data/Messaging.kt | 81 ----------- .../presentation/MainActivity.kt | 126 ++++++++++++++---- 5 files changed, 179 insertions(+), 178 deletions(-) delete mode 100644 wear/build.gradle create mode 100644 wear/build.gradle.kts delete mode 100644 wear/src/main/java/com/example/util/simpletimetracker/data/Messaging.kt diff --git a/wear/build.gradle b/wear/build.gradle deleted file mode 100644 index 0a2064e1..00000000 --- a/wear/build.gradle +++ /dev/null @@ -1,71 +0,0 @@ -plugins { - id 'com.android.application' - id 'org.jetbrains.kotlin.android' -} - -android { - namespace 'com.example.util.simpletimetracker' - compileSdk 34 - - defaultConfig { - applicationId "com.razeeman.util.simpletimetracker" - minSdk 26 - targetSdk 34 - versionCode 1 - versionName "1.0" - vectorDrawables { - useSupportLibrary true - } - - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } - buildFeatures { - compose true - } - composeOptions { - kotlinCompilerExtensionVersion '1.4.0-alpha02' - } - packagingOptions { - resources { - excludes += '/META-INF/{AL2.0,LGPL2.1}' - } - } -} - -dependencies { - var compose_version = "1.3.1" - var wear_compose_version = "1.1.0" - // Custom Dependencies - implementation 'com.google.android.gms:play-services-wearable:18.0.0' - implementation 'com.google.android.horologist:horologist-compose-layout:0.2.7' - - // Default Dependencies - implementation 'androidx.core:core-ktx:1.7.0' - implementation 'com.google.android.gms:play-services-wearable:17.1.0' - implementation 'androidx.percentlayout:percentlayout:1.0.0' - implementation 'androidx.legacy:legacy-support-v4:1.0.0' - implementation 'androidx.recyclerview:recyclerview:1.2.1' - implementation "androidx.compose.ui:ui:$compose_version" - implementation "androidx.wear.compose:compose-material:$wear_compose_version" - implementation "androidx.wear.compose:compose-foundation:$wear_compose_version" - implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" - implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' - implementation 'androidx.activity:activity-compose:1.3.1' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" - wearApp project(":wear") -} \ No newline at end of file diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts new file mode 100644 index 00000000..6838683d --- /dev/null +++ b/wear/build.gradle.kts @@ -0,0 +1,77 @@ +plugins { + id("com.android.application") + id("kotlin-android") +} + +android { + namespace = "com.example.util.simpletimetracker" + compileSdk = 34 + + defaultConfig { + applicationId = "com.razeeman.util.simpletimetracker" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + vectorDrawables { + useSupportLibrary = true + } + + } + + buildTypes { + getByName("debug") { + applicationIdSuffix = ".debug" + } + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.4.0-alpha02" + } + packagingOptions { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + var compose_version = "1.3.1" + var wear_compose_version = "1.1.0" + // Custom Dependencies + implementation("com.google.android.horologist:horologist-compose-layout:0.2.7") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") + implementation(project(":wearrpc")) + + // Default Dependencies + implementation("androidx.core:core-ktx:1.7.0") + implementation("com.google.android.gms:play-services-wearable:17.1.0") + implementation("androidx.percentlayout:percentlayout:1.0.0") + implementation("androidx.legacy:legacy-support-v4:1.0.0") + implementation("androidx.recyclerview:recyclerview:1.2.1") + implementation("androidx.compose.ui:ui:$compose_version") + implementation("androidx.wear.compose:compose-material:$wear_compose_version") + implementation("androidx.wear.compose:compose-foundation:$wear_compose_version") + implementation("androidx.compose.ui:ui-tooling-preview:$compose_version") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1") + implementation("androidx.activity:activity-compose:1.3.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version") + debugImplementation("androidx.compose.ui:ui-tooling:$compose_version") + debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version") +} \ No newline at end of file diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro index 481bb434..ff59496d 100644 --- a/wear/proguard-rules.pro +++ b/wear/proguard-rules.pro @@ -1,6 +1,6 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html diff --git a/wear/src/main/java/com/example/util/simpletimetracker/data/Messaging.kt b/wear/src/main/java/com/example/util/simpletimetracker/data/Messaging.kt deleted file mode 100644 index 9d4a4b72..00000000 --- a/wear/src/main/java/com/example/util/simpletimetracker/data/Messaging.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ -package com.example.util.simpletimetracker.data - -import android.content.Context -import android.util.Log -import com.google.android.gms.tasks.Task -import com.google.android.gms.tasks.Tasks -import com.google.android.gms.wearable.CapabilityClient -import com.google.android.gms.wearable.CapabilityInfo -import com.google.android.gms.wearable.Wearable -import com.example.util.simpletimetracker.presentation.LOG_TAG - -const val START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME = "start_time_tracking_activity" - -class Messaging { - fun startTimeTracking( - context: Context, - activity: String, - tag: String, - ) { - Thread( - Runnable { - startTimeTrackingTask(context, activity, tag) - }, - ).start() - } - - private fun startTimeTrackingTask( - context: Context, - activity: String, - tag: String, - ) { - // Find all nodes which support the time tracking message - val capabilityInfo: CapabilityInfo = - Tasks.await( - Wearable.getCapabilityClient(context) - .getCapability( - START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME, - CapabilityClient.FILTER_REACHABLE, - ), - ) - - // Choose the best node (the closest one connected to the watch - val nodes = capabilityInfo.nodes - val bestNode = nodes.firstOrNull { it.isNearby }?.id ?: nodes.firstOrNull()?.id - - // Send the message - val message = "$activity|$tag" - bestNode?.also { nodeId -> - Log.i(LOG_TAG, "Sending message to $bestNode") - val sendTask: Task<*> = - Wearable.getMessageClient(context).sendMessage( - nodeId, - "/$START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME", - message.toByteArray(), - ).apply { - addOnSuccessListener { - Log.i( - LOG_TAG, - "Sent $START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME message: $message", - ) - } - addOnFailureListener { - Log.e( - LOG_TAG, - "Failed to send $START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME message: $message", - ) - } - } - } ?: run { - Log.e( - LOG_TAG, - "No nodes found with the capability $START_TIME_TRACKING_ACTIVITY_CAPABILITY_NAME", - ) - } - } -} diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt index 31944722..320632ba 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -7,6 +7,7 @@ package com.example.util.simpletimetracker.presentation import android.content.Context import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background @@ -25,9 +26,13 @@ import com.google.android.horologist.compose.focus.rememberActiveFocusRequester import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi import com.google.android.horologist.compose.rotaryinput.* import com.example.util.simpletimetracker.R -import com.example.util.simpletimetracker.data.Messaging import com.example.util.simpletimetracker.data.getTimeTrackingActivities import com.example.util.simpletimetracker.presentation.theme.SimpleTimeTrackerForWearOSTheme +import com.example.util.simpletimetracker.wearrpc.Messenger +import com.example.util.simpletimetracker.wearrpc.WearRPCClient +//import com.example.util.simpletimetracker.wearrpc.CurrentActivity +import kotlinx.coroutines.* +//import java.util.Date const val LOG_TAG = "com.razeeman.util.simpletimetracker" @@ -42,6 +47,8 @@ class MainActivity : ComponentActivity() { @Composable fun WearApp() { + val context = LocalContext.current + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) SimpleTimeTrackerForWearOSTheme { val scrollState = rememberScalingLazyListState() Scaffold( @@ -55,11 +62,72 @@ fun WearApp() { PositionIndicator(scalingLazyListState = scrollState) }, ) { - ActivityList(scrollState) + ScalingLazyColumn ( + modifier = Modifier.fillMaxSize(), + autoCentering = AutoCenteringParams(itemIndex = 0) + ){ + item { + DebugButton( + label = "Ping", + onClick = { + Log.i(LOG_TAG, "Pinging mobile ...") + scope.async { + val response = WearRPCClient(Messenger(context)).ping("Ping button") + Log.i(LOG_TAG, response) + } + } + ) + } + item { + DebugButton(label="Query Activities", onClick = { + Log.i(LOG_TAG, "Querying activities ...") + scope.async { + val response = WearRPCClient(Messenger(context)).queryActivities() + Log.i(LOG_TAG, "Received ${response.size} activities") + for (activity in response) { + Log.i(LOG_TAG, " - ${activity.id}: ${activity.name}") + } + } + }) + } + } + +// ActivityList(scrollState) } } } +@Composable +fun DebugButton(label: String, onClick: () -> Unit) { + Chip( + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(top = 10.dp), + label = { Text(label) }, + onClick = onClick, + ) +} + +@OptIn(ExperimentalHorologistComposeLayoutApi::class) +@Composable +fun ScrollingColumn(scrollState: ScalingLazyListState = rememberScalingLazyListState(), content: @Composable () -> Unit) { + val focusRequester = rememberActiveFocusRequester() + val rotaryHapticFeedback = rememberRotaryHapticFeedback() + ScalingLazyColumn( + modifier = + Modifier + .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) + .fillMaxSize() + .background(MaterialTheme.colors.background) + .selectableGroup(), + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.Center, + state = scrollState, + ) { + content + } +} + @OptIn(ExperimentalHorologistComposeLayoutApi::class) @Composable fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListState()) { @@ -68,11 +136,11 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat val rotaryHapticFeedback = rememberRotaryHapticFeedback() ScalingLazyColumn( modifier = - Modifier - .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) - .fillMaxSize() - .background(MaterialTheme.colors.background) - .selectableGroup(), + Modifier + .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) + .fillMaxSize() + .background(MaterialTheme.colors.background) + .selectableGroup(), contentPadding = PaddingValues(10.dp), verticalArrangement = Arrangement.Center, state = scrollState, @@ -125,11 +193,12 @@ fun ActivityWithTag( icon: Int = R.drawable.baseline_question_mark_24, ) { val context = LocalContext.current + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) Chip( modifier = - Modifier - .fillMaxWidth(0.9f) - .padding(top = 10.dp), + Modifier + .fillMaxWidth(0.9f) + .padding(top = 10.dp), icon = { ActivityIcon(iconId = icon) }, @@ -151,7 +220,7 @@ fun ActivityWithTag( ChipDefaults.chipColors( backgroundColor = color, ), - onClick = { startTimeTracking(context, name, tag) }, + onClick = { startTimeTracking(scope, context) }, ) } @@ -162,11 +231,12 @@ fun ActivityWithoutTag( icon: Int = R.drawable.baseline_question_mark_24, ) { val context = LocalContext.current + val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) Chip( modifier = - Modifier - .fillMaxWidth(0.9f) - .padding(top = 10.dp), + Modifier + .fillMaxWidth(0.9f) + .padding(top = 10.dp), icon = { ActivityIcon(iconId = icon) }, @@ -181,7 +251,7 @@ fun ActivityWithoutTag( ChipDefaults.chipColors( backgroundColor = color, ), - onClick = { startTimeTracking(context, name, "") }, + onClick = { startTimeTracking(scope, context) }, ) } @@ -191,16 +261,22 @@ fun ActivityIcon(iconId: Int) { painter = painterResource(id = iconId), contentDescription = "activity icon", modifier = - Modifier - .size(ChipDefaults.IconSize) - .wrapContentSize(align = Alignment.Center), + Modifier + .size(ChipDefaults.IconSize) + .wrapContentSize(align = Alignment.Center), ) } -fun startTimeTracking( - context: Context, - activity: String, - tag: String, -) { - Messaging().startTimeTracking(context, activity, tag) -} +fun startTimeTracking(scope: CoroutineScope, context: Context) { +// val activities = arrayOf( +// CurrentActivity( +// id, +// name, +// startedAt = Date(), +// arrayOf(/* tags */), +// ) +// ) +// scope.async { +// WearRPCClient(context).setCurrentActivities(activities) +// } +} \ No newline at end of file -- 2.51.2 From dac59fb786f819923c37a0681298b35499c7be4f Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 10 Feb 2024 00:48:54 -0700 Subject: [PATCH 06/56] doc: Add WearOS status to README --- README.md | 33 ++++++++++++++++++ docs/simpletimetracker-wearos-list-middle.png | Bin 0 -> 49034 bytes docs/simpletimetracker-wearos-list-start.png | Bin 0 -> 33325 bytes 3 files changed, 33 insertions(+) create mode 100644 docs/simpletimetracker-wearos-list-middle.png create mode 100644 docs/simpletimetracker-wearos-list-start.png diff --git a/README.md b/README.md index 13cdd0a6..218d553f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,36 @@ +

+ +# Simple Time Tracker for Wear OS + +A fork of Simple Time Tracker with WearOS support. + + +[![](https://badgen.net/github/license/thehale/SimpleTimeTracker-WearOS)](https://github.com/thehale/SimpleTimeTracker-WearOS/blob/master/LICENSE) +[![](https://badgen.net/badge/icon/Sponsor/pink?icon=github&label)](https://github.com/sponsors/thehale) +[![](https://img.shields.io/badge/Follow-thehale-0A66C2?logo=linkedin)](https://www.linkedin.com/comm/mynetwork/discovery-see-all?usecase=PEOPLE_FOLLOWS&followMember=thehale) + +| | | +| ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| ![Application screenshot](docs/simpletimetracker-wearos-list-start.png) | ![Application screenshot](docs/simpletimetracker-wearos-list-middle.png) | + +
+ +## WearOS Status + +While **not ready for production**, this project has a solid foundation for Wear +<-> +Mobile communication and is now open to contributors for adding functionality +and UI/Design. + +Check the GitHub Issues for details about the next steps. Once this fork reaches +stability, we will open a PR back into the upstream application so everyone can +benefit from these improvements! + +------ +------ + +*Original README...* + # Simple Time Tracker Simple app that helps track how much time you spend on all the useless activities in the world. diff --git a/docs/simpletimetracker-wearos-list-middle.png b/docs/simpletimetracker-wearos-list-middle.png new file mode 100644 index 0000000000000000000000000000000000000000..6e3a44e8951d5e76649354fa1ee4d31928f15987 GIT binary patch literal 49034 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 VDsZlclYxPO!PC{xWt~$(695pU+F1Yq literal 0 HcmV?d00001 diff --git a/docs/simpletimetracker-wearos-list-start.png b/docs/simpletimetracker-wearos-list-start.png new file mode 100644 index 0000000000000000000000000000000000000000..8d635bc2d35172952bf5053eeb3a3b93651f817f GIT binary patch literal 33325 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 HB{Ts57IZ$_ literal 0 HcmV?d00001 -- 2.51.2 From c1a2ba8f0f277aa23709de4e9545cd3cdc5237a6 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 10 Feb 2024 00:57:32 -0700 Subject: [PATCH 07/56] chore: Add MPL-2.0 LICENSE header to WearOS code I will be initially licensing my contributions under the MPL-2.0 since I consider it a fairer license than the GPL. - See https://jhale.dev/posts/why-i-use-the-mpl/ Since the MPL is compatible with the GPL, this dual-licensing does not create any licensing conflicts. Any concerns about dual licensing can be sent to me privately. I am happy to answer any questions and address any concerns. --- .../com/example/util/simpletimetracker/wear/DomainAPI.kt | 7 ++++++- app/src/main/res/values/wear.xml | 3 +++ wear/build.gradle.kts | 6 ++++++ wearrpc/build.gradle.kts | 5 +++++ .../simpletimetracker/wearrpc/ExampleInstrumentedTest.kt | 5 +++++ .../java/com/example/util/simpletimetracker/wearrpc/DTO.kt | 5 +++++ .../example/util/simpletimetracker/wearrpc/Messenger.kt | 5 +++++ .../com/example/util/simpletimetracker/wearrpc/Request.kt | 5 +++++ .../util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt | 5 +++++ .../util/simpletimetracker/wearrpc/WearRPCClient.kt | 5 +++++ .../util/simpletimetracker/wearrpc/WearRPCException.kt | 5 +++++ .../util/simpletimetracker/wearrpc/WearRPCServerTest.kt | 5 +++++ 12 files changed, 60 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index b0f12f3d..4db06634 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -1,4 +1,9 @@ -package com.example.util.simpletimetracker.wear +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + package com.example.util.simpletimetracker.wear import com.example.util.simpletimetracker.domain.interactor.PrefsInteractor import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor diff --git a/app/src/main/res/values/wear.xml b/app/src/main/res/values/wear.xml index 19fe4bcd..9b71c974 100644 --- a/app/src/main/res/values/wear.xml +++ b/app/src/main/res/values/wear.xml @@ -1,4 +1,7 @@ + diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 6838683d..8f25dea1 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -1,3 +1,9 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ + plugins { id("com.android.application") id("kotlin-android") diff --git a/wearrpc/build.gradle.kts b/wearrpc/build.gradle.kts index a2689fcf..fe182384 100644 --- a/wearrpc/build.gradle.kts +++ b/wearrpc/build.gradle.kts @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ plugins { id("com.android.library") id("org.jetbrains.kotlin.android") diff --git a/wearrpc/src/androidTest/java/com/example/util/simpletimetracker/wearrpc/ExampleInstrumentedTest.kt b/wearrpc/src/androidTest/java/com/example/util/simpletimetracker/wearrpc/ExampleInstrumentedTest.kt index a19b76ab..7ac194af 100644 --- a/wearrpc/src/androidTest/java/com/example/util/simpletimetracker/wearrpc/ExampleInstrumentedTest.kt +++ b/wearrpc/src/androidTest/java/com/example/util/simpletimetracker/wearrpc/ExampleInstrumentedTest.kt @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package com.example.util.simpletimetracker.wearrpc import androidx.test.platform.app.InstrumentationRegistry diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt index 1a13aa77..5840ad0b 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package com.example.util.simpletimetracker.wearrpc /** diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt index 022777a8..c77dfc75 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package com.example.util.simpletimetracker.wearrpc import android.content.Context diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt index 18609a22..730bea10 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package com.example.util.simpletimetracker.wearrpc object Request { diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt index c2384305..78e4cc60 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package com.example.util.simpletimetracker.wearrpc interface SimpleTimeTrackerAPI { diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt index 214afd3f..aaa25d6c 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package com.example.util.simpletimetracker.wearrpc import android.util.Log diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCException.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCException.kt index a45b160e..affd2bc8 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCException.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCException.kt @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package com.example.util.simpletimetracker.wearrpc class WearRPCException(message: String) : Exception(message) {} \ No newline at end of file diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt index b2276d4a..7e2bd145 100644 --- a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt +++ b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt @@ -1,3 +1,8 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ package com.example.util.simpletimetracker.wearrpc import org.junit.Test -- 2.51.2 From 70fdb856419d0cec74bfbdd0f921ec4208b3d0cc Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 10 Feb 2024 03:01:56 -0700 Subject: [PATCH 08/56] refactor(wear): Standardize nomenclature Use `query` consistently instead of occasionally switching to `get`. --- .../util/simpletimetracker/wearrpc/Request.kt | 9 ++++---- .../wearrpc/WearRPCClient.kt | 2 +- .../wearrpc/WearRPCServer.kt | 21 ++++++++++++------- .../wearrpc/WearRPCServerTest.kt | 6 +++--- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt index 730bea10..b2fe46d7 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt @@ -7,8 +7,9 @@ package com.example.util.simpletimetracker.wearrpc object Request { val PING = "/stt//GET/ping" - val GET_ACTIVITIES = "/stt//GET/activities" - val GET_CURRENT_ACTIVITIES = "/stt//GET/activities/current" - val START_ACTIVITIES = "/stt//POST/activities/current" - val GET_TAGS_FOR_ACTIVITY = "/stt//GET/activities/:ID/tags" + val QUERY_ACTIVITIES = "/stt//GET/activities" + val QUERY_CURRENT_ACTIVITIES = "/stt//GET/activities/current" + val SET_CURRENT_ACTIVITIES = "/stt//POST/activities/current" + val QUERY_TAGS_FOR_ACTIVITY = "/stt//GET/activities/:ID/tags" + val QUERY_SETTINGS = "/stt//GET/settings" } \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt index aaa25d6c..66f42feb 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt @@ -20,7 +20,7 @@ class WearRPCClient(private val messenger: Messenger): SimpleTimeTrackerAPI { } override suspend fun queryActivities(): Array { - val response = messenger.sendMessage(Request.GET_ACTIVITIES) + val response = messenger.sendMessage(Request.QUERY_ACTIVITIES) if (response != null) { Log.i(TAG, String(response)) val collectionType = object : TypeToken>() {}.type diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt index f1fa4313..c1f1acbb 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt @@ -14,20 +14,21 @@ class WearRPCServer(private val api: SimpleTimeTrackerAPI) { if (path.startsWith("/stt")) { when (path) { Request.PING -> return onPing(request) - Request.GET_ACTIVITIES -> return onGetActivities() - Request.GET_CURRENT_ACTIVITIES -> return onGetCurrentActivities() - Request.START_ACTIVITIES -> return onStartActivities(request) - Request.GET_TAGS_FOR_ACTIVITY -> return onGetTagsForActivity(request) + Request.QUERY_ACTIVITIES -> return onQueryActivities() + Request.QUERY_CURRENT_ACTIVITIES -> return onQueryCurrentActivities() + Request.SET_CURRENT_ACTIVITIES -> return onSetCurrentActivities(request) + Request.QUERY_TAGS_FOR_ACTIVITY -> return onQueryTagsForActivity(request) + Request.QUERY_SETTINGS -> return onQuerySettings() else -> throw WearRPCException("$path is an invalid RPC call") } } else return null } - private suspend fun onGetTagsForActivity(request: ByteArray): ByteArray? { + private suspend fun onQueryTagsForActivity(request: ByteArray): ByteArray? { TODO("Not yet implemented") } - private suspend fun onStartActivities(request: ByteArray): ByteArray? { + private suspend fun onSetCurrentActivities(request: ByteArray): ByteArray? { TODO("Not yet implemented") } @@ -35,11 +36,15 @@ class WearRPCServer(private val api: SimpleTimeTrackerAPI) { return request } - private suspend fun onGetActivities(): ByteArray? { + private suspend fun onQueryActivities(): ByteArray? { return Gson().toJson(api.queryActivities()).toByteArray() } - private suspend fun onGetCurrentActivities(): ByteArray? { + private suspend fun onQueryCurrentActivities(): ByteArray? { return Gson().toJson(api.queryCurrentActivities()).toByteArray() } + + private suspend fun onQuerySettings(): ByteArray? { + TODO("Not yet implemented") + } } \ No newline at end of file diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt index 7e2bd145..b5e1b77b 100644 --- a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt +++ b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt @@ -55,19 +55,19 @@ class PingEndpointTest: WearRPCServerTestBase() { class GetActivitiesTest: WearRPCServerTestBase() { @Test fun returns_no_activities_when_none_are_available() = runTest { - val response = rpc.onRequest(Request.GET_ACTIVITIES, ByteArray(0)) + val response = rpc.onRequest(Request.QUERY_ACTIVITIES, ByteArray(0)) assertResponseEquals("[]", response) } @Test fun returns_one_activity_when_one_exists() = runTest { api.mock_queryActivities(arrayOf(Activity(42, "Chores"))) - val response = rpc.onRequest(Request.GET_ACTIVITIES, ByteArray(0)) + val response = rpc.onRequest(Request.QUERY_ACTIVITIES, ByteArray(0)) assertResponseEquals("[{\"id\":42,\"name\":\"Chores\"}]", response) } @Test fun returns_all_existing_activities() = runTest { api.mock_queryActivities(arrayOf(Activity(13, "Singing"), Activity(24, "Homework"))) - val response = rpc.onRequest(Request.GET_ACTIVITIES, ByteArray(0)) + val response = rpc.onRequest(Request.QUERY_ACTIVITIES, ByteArray(0)) assertResponseEquals("[{\"id\":13,\"name\":\"Singing\"},{\"id\":24,\"name\":\"Homework\"}]", response) } } -- 2.51.2 From 1f323d81531600e8bf4a6ee9d41429df3562b909 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 10 Feb 2024 03:03:41 -0700 Subject: [PATCH 09/56] doc: Add Javadocs and implementation guides Added several comments explaining my vision for how the remaining communication layer methods should be implemented. Intended to enable the other developers eager to contribute to work independently on advancing the state of this project. --- .../util/simpletimetracker/wear/DomainAPI.kt | 17 +++++++++ .../wearrpc/SimpleTimeTrackerAPI.kt | 38 ++++++++++++++++--- .../wearrpc/WearRPCClient.kt | 10 +++++ .../wearrpc/WearRPCServer.kt | 8 ++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index 4db06634..59f3c5a8 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -31,18 +31,35 @@ class DomainAPI ( override suspend fun queryCurrentActivities(): Array { TODO("Not yet implemented") + // 1. Pull the current activities from the runningRecordInteractor + // 2. Return as an `Array` } override suspend fun setCurrentActivities(activities: Array) { TODO("Not yet implemented") + // This is a little tricky... The given `activities` should be considered a declarative + // statement of the activities expected to be running upon successful completion of this + // method. + + // Currently running activities not in the given Array should be stopped + + // Activities in the given Array that are not running should be started + + // For activities in the given Array which are running... + // If the start dates + tags are unchanged, then leave the activity running. + // If the start dates and/or tags are different, stop the current running activity + // instance and restart it as of the requested start date. + } override suspend fun queryTagsForActivity(activityId: Long): Array { TODO("Not yet implemented") + // Look up the tags which can be associated with this activity and return them } override suspend fun querySettings(): Settings { TODO("Not yet implemented") + // Obtain the desired Settings from the prefsInteractor and return them. } } \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt index 78e4cc60..9ba6bc36 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt @@ -6,23 +6,49 @@ package com.example.util.simpletimetracker.wearrpc interface SimpleTimeTrackerAPI { - /** /stt//GET/ping */ + /** + * /stt//GET/ping + * + * Echos the message it receives + * + * Primarily used to test request/response functionality + */ suspend fun ping(str: String): String { return str } - /** /stt//GET/activities */ + /** + * /stt//GET/activities + * + * Retrieves a list of all the time-tracking activities available for selection + */ suspend fun queryActivities(): Array - /** /stt//GET/activities/current */ + /** + * /stt//GET/activities/current + * + * Retrieves a list of the currently running activity/activities + */ suspend fun queryCurrentActivities(): Array - /** /stt//POST/activities/current */ + /** + * /stt//POST/activities/current + * + * Replaces the currently running activity/activities with the given activities + */ suspend fun setCurrentActivities(activities: Array): Unit - /** /stt//GET/activities/:ID/tags */ + /** + * /stt//GET/activities/:ID/tags + * + * Retrieves the tags available for association with the activity with the given ID + */ suspend fun queryTagsForActivity(activityId: Long): Array - /** /stt//GET/settings */ + /** + * /stt//GET/settings + * + * Retrieves the settings relevant to time tracking behavior + */ suspend fun querySettings(): Settings } diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt index 66f42feb..05e5db32 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt @@ -30,17 +30,27 @@ class WearRPCClient(private val messenger: Messenger): SimpleTimeTrackerAPI { override suspend fun queryCurrentActivities(): Array { TODO("Not yet implemented") + // 1. Send a Request.QUERY_CURRENT_ACTIVITIES message + // 2. Parse the resulting JSON bytes and return it } override suspend fun setCurrentActivities(activities: Array) { TODO("Not yet implemented") + // 1. Serialize the given activities to JSON + // 2. Send a Request.SET_CURRENT_ACTIVITIES message + // 3. Wait for response (successful/error) } override suspend fun queryTagsForActivity(activityId: Long): Array { TODO("Not yet implemented") + // 1. Serialize the given activityID to bytes + // 2. Send a Request.QUERY_TAGS_FOR_ACTIVITY message + // 3. Deserialize the JSON response and return. } override suspend fun querySettings(): Settings { TODO("Not yet implemented") + // 1. Send a Request.QUERY_SETTINGS message + // 2. Deserialize the JSON response and return } } \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt index c1f1acbb..d871e28e 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt @@ -26,10 +26,16 @@ class WearRPCServer(private val api: SimpleTimeTrackerAPI) { private suspend fun onQueryTagsForActivity(request: ByteArray): ByteArray? { TODO("Not yet implemented") + // 1. Parse the activity id (Long) from the request (throw an exception if not there) + // 2. Ask this.api for the associated tags + // 3. JSON-serialize the tag list into a ByteArray to return as a response. } private suspend fun onSetCurrentActivities(request: ByteArray): ByteArray? { TODO("Not yet implemented") + // 1. Deserialize the JSON CurrentActivity records from the request + // 2. Delegate to this.api + // 3. Return a success message (let unresolvable exceptions from this.api bubble up) } private suspend fun onPing(request: ByteArray): ByteArray? { @@ -46,5 +52,7 @@ class WearRPCServer(private val api: SimpleTimeTrackerAPI) { private suspend fun onQuerySettings(): ByteArray? { TODO("Not yet implemented") + // 1. Obtain the settings from this.api + // 2. Serialize to JSON and return the bytes } } \ No newline at end of file -- 2.51.2 From 065eb123e08ff107e1aac45aeca177b081b64c26 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sun, 11 Feb 2024 01:23:05 -0700 Subject: [PATCH 10/56] feat: Add `icon` and `color` to Activities Needed for nicer rendering in the WearOS UI. --- .../com/example/util/simpletimetracker/wear/DomainAPI.kt | 6 +++++- .../java/com/example/util/simpletimetracker/wearrpc/DTO.kt | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index 59f3c5a8..be9d0ba1 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -25,7 +25,11 @@ class DomainAPI ( override suspend fun queryActivities(): Array { return recordTypeInteractor.getAll() .filter { recordType -> !recordType.hidden } - .map { recordType -> Activity(recordType.id, recordType.name) } + .map { recordType -> + val color = appColorMapper.mapToColorInt(recordType.color) + val hex = String.format("#%06X", (0xFFFFFF and color)) + Activity(recordType.id, recordType.name, recordType.icon, hex) + } .toTypedArray() } diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt index 5840ad0b..64012930 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt @@ -13,7 +13,7 @@ package com.example.util.simpletimetracker.wearrpc import java.util.Date -data class Activity(val id: Long, val name: String /* , val color: String, val icon: ByteArray */) +data class Activity(val id: Long, val name: String, val icon: String, val color: String) data class CurrentActivity(val id: Long, val name: String, val startedAt: Date, val tags: Array) { override fun equals(other: Any?): Boolean { -- 2.51.2 From 3eff9add4abfada2f5245c6aebe6a72b30d0ec15 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sun, 11 Feb 2024 01:25:34 -0700 Subject: [PATCH 11/56] 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 --- wear/build.gradle.kts | 1 + .../presentation/MainActivity.kt | 277 ++++++------------ 2 files changed, 87 insertions(+), 191 deletions(-) diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 8f25dea1..1dca9ed1 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { // Custom Dependencies implementation("com.google.android.horologist:horologist-compose-layout:0.2.7") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") + implementation("androidx.compose.material:material-icons-core:1.6.1") implementation(project(":wearrpc")) // Default Dependencies diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt index 320632ba..9b803893 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -5,36 +5,57 @@ */ package com.example.util.simpletimetracker.presentation -import android.content.Context import android.os.Bundle -import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.wear.compose.material.* -import com.google.android.horologist.compose.focus.rememberActiveFocusRequester -import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi -import com.google.android.horologist.compose.rotaryinput.* -import com.example.util.simpletimetracker.R -import com.example.util.simpletimetracker.data.getTimeTrackingActivities +import androidx.wear.compose.material.Button +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.OutlinedButton +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.ScalingLazyColumn +import androidx.wear.compose.material.ScalingLazyListState +import androidx.wear.compose.material.Text +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.Vignette +import androidx.wear.compose.material.VignettePosition +import androidx.wear.compose.material.rememberScalingLazyListState +import androidx.wear.compose.material.scrollAway import com.example.util.simpletimetracker.presentation.theme.SimpleTimeTrackerForWearOSTheme +import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.Messenger import com.example.util.simpletimetracker.wearrpc.WearRPCClient -//import com.example.util.simpletimetracker.wearrpc.CurrentActivity -import kotlinx.coroutines.* -//import java.util.Date - -const val LOG_TAG = "com.razeeman.util.simpletimetracker" +import com.google.android.horologist.compose.focus.rememberActiveFocusRequester +import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi +import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticFeedback +import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -47,8 +68,6 @@ class MainActivity : ComponentActivity() { @Composable fun WearApp() { - val context = LocalContext.current - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) SimpleTimeTrackerForWearOSTheme { val scrollState = rememberScalingLazyListState() Scaffold( @@ -62,81 +81,31 @@ fun WearApp() { PositionIndicator(scalingLazyListState = scrollState) }, ) { - ScalingLazyColumn ( - modifier = Modifier.fillMaxSize(), - autoCentering = AutoCenteringParams(itemIndex = 0) - ){ - item { - DebugButton( - label = "Ping", - onClick = { - Log.i(LOG_TAG, "Pinging mobile ...") - scope.async { - val response = WearRPCClient(Messenger(context)).ping("Ping button") - Log.i(LOG_TAG, response) - } - } - ) - } - item { - DebugButton(label="Query Activities", onClick = { - Log.i(LOG_TAG, "Querying activities ...") - scope.async { - val response = WearRPCClient(Messenger(context)).queryActivities() - Log.i(LOG_TAG, "Received ${response.size} activities") - for (activity in response) { - Log.i(LOG_TAG, " - ${activity.id}: ${activity.name}") - } - } - }) - } - } - -// ActivityList(scrollState) + ActivityList(scrollState) } } } -@Composable -fun DebugButton(label: String, onClick: () -> Unit) { - Chip( - modifier = Modifier - .fillMaxWidth(0.9f) - .padding(top = 10.dp), - label = { Text(label) }, - onClick = onClick, - ) -} - -@OptIn(ExperimentalHorologistComposeLayoutApi::class) -@Composable -fun ScrollingColumn(scrollState: ScalingLazyListState = rememberScalingLazyListState(), content: @Composable () -> Unit) { - val focusRequester = rememberActiveFocusRequester() - val rotaryHapticFeedback = rememberRotaryHapticFeedback() - ScalingLazyColumn( - modifier = - Modifier - .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) - .fillMaxSize() - .background(MaterialTheme.colors.background) - .selectableGroup(), - contentPadding = PaddingValues(10.dp), - verticalArrangement = Arrangement.Center, - state = scrollState, - ) { - content - } -} @OptIn(ExperimentalHorologistComposeLayoutApi::class) @Composable fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListState()) { - val activities = getTimeTrackingActivities() + val context = LocalContext.current + var activities: Array by remember { mutableStateOf(arrayOf()) } + var queryCount by remember { mutableIntStateOf(0) } + LaunchedEffect( + key1 = queryCount, + block = { + async(Dispatchers.Default) { + activities = WearRPCClient(Messenger(context)).queryActivities() + } + }, + ) + val focusRequester = rememberActiveFocusRequester() val rotaryHapticFeedback = rememberRotaryHapticFeedback() ScalingLazyColumn( - modifier = - Modifier + modifier = Modifier .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) .fillMaxSize() .background(MaterialTheme.colors.background) @@ -146,137 +115,63 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat state = scrollState, ) { for (activity in activities) { - if (activity.tags.isEmpty()) { - item { - Activity( - name = activity.name, - tag = "", - color = activity.color, - icon = activity.iconId, - ) - } - } else { - for (tag in activity.tags) { - item { - Activity( - name = activity.name, - tag = tag, - color = activity.color, - icon = activity.iconId, - ) - } - } + item { + Activity( + id = activity.id, + name = activity.name, + color = Color(android.graphics.Color.parseColor(activity.color)), + icon = activity.icon, + ) } } + item { + OutlinedButton( + onClick = { + queryCount++ + Toast.makeText(context, "Refreshing activities", Toast.LENGTH_SHORT).show() + }, + content = { + Icon(Icons.Rounded.Refresh, contentDescription = "Refresh Activities List") + }, + modifier = Modifier.padding(all = 8.dp), + ) + } } } @Composable fun Activity( + id: Long, name: String, - tag: String, color: Color = Color(96, 125, 139, 255), - icon: Int = R.drawable.baseline_question_mark_24, + icon: String = "?", ) { - if (tag.isNotEmpty()) { - ActivityWithTag(name = name, tag = tag, color = color, icon = icon) + val context = LocalContext.current + val briefIcon = if (icon.startsWith("ic_")) { + "?" } else { - ActivityWithoutTag(name = name, color = color, icon = icon) + icon.substring(0, icon.length.coerceAtMost(2)) } -} - -@Composable -fun ActivityWithTag( - name: String, - tag: String, - color: Color = Color(96, 125, 139, 255), - icon: Int = R.drawable.baseline_question_mark_24, -) { - val context = LocalContext.current - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) Chip( - modifier = - Modifier + modifier = Modifier .fillMaxWidth(0.9f) .padding(top = 10.dp), - icon = { - ActivityIcon(iconId = icon) - }, label = { Text( - text = tag, + text = "$briefIcon : $name", maxLines = 1, overflow = TextOverflow.Ellipsis, ) }, - secondaryLabel = { - Text( - text = name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + colors = ChipDefaults.chipColors( + backgroundColor = color, + ), + onClick = { + Toast.makeText( + context, + "Starting `$name` (id: $id)\n[Not Yet Implemented]", Toast.LENGTH_SHORT, + ).show() }, - colors = - ChipDefaults.chipColors( - backgroundColor = color, - ), - onClick = { startTimeTracking(scope, context) }, - ) -} - -@Composable -fun ActivityWithoutTag( - name: String, - color: Color = Color(96, 125, 139, 255), - icon: Int = R.drawable.baseline_question_mark_24, -) { - val context = LocalContext.current - val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) - Chip( - modifier = - Modifier - .fillMaxWidth(0.9f) - .padding(top = 10.dp), - icon = { - ActivityIcon(iconId = icon) - }, - label = { - Text( - text = name, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - colors = - ChipDefaults.chipColors( - backgroundColor = color, - ), - onClick = { startTimeTracking(scope, context) }, - ) -} - -@Composable -fun ActivityIcon(iconId: Int) { - Icon( - painter = painterResource(id = iconId), - contentDescription = "activity icon", - modifier = - Modifier - .size(ChipDefaults.IconSize) - .wrapContentSize(align = Alignment.Center), ) } -fun startTimeTracking(scope: CoroutineScope, context: Context) { -// val activities = arrayOf( -// CurrentActivity( -// id, -// name, -// startedAt = Date(), -// arrayOf(/* tags */), -// ) -// ) -// scope.async { -// WearRPCClient(context).setCurrentActivities(activities) -// } -} \ No newline at end of file -- 2.51.2 From c99ee59520a99fd0f93c1caf91feeaadf97259a9 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sun, 11 Feb 2024 01:32:00 -0700 Subject: [PATCH 12/56] chore: Remove `TimeTrackingActivity` These hard-coded values were useful for the original proof-of-concept, but now that we have a dynamic activity list, they are no longer necessary. --- .../data/TimeTrackingActivity.kt | 77 ------------------- 1 file changed, 77 deletions(-) delete mode 100644 wear/src/main/java/com/example/util/simpletimetracker/data/TimeTrackingActivity.kt diff --git a/wear/src/main/java/com/example/util/simpletimetracker/data/TimeTrackingActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/data/TimeTrackingActivity.kt deleted file mode 100644 index 5d74b988..00000000 --- a/wear/src/main/java/com/example/util/simpletimetracker/data/TimeTrackingActivity.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ -package com.example.util.simpletimetracker.data - -import androidx.compose.ui.graphics.Color -import com.example.util.simpletimetracker.R - -class TimeTrackingActivity( - val name: String, - val tags: List, - val color: Color, - val iconId: Int, -) - -fun getTimeTrackingActivities(): List { - return listOf( - activity("TODOs", listOf("Chores", "Shopping")), - activity("Travel", listOf("Bike", "Car", "Plane", "Scooter", "Public Transit")), - activity("Planning", listOf("Goals", "Processing", "Reflection")), - activity("Personal Learning", listOf("Industry", "Hobbies", "Music")), - activity("Professional Development", listOf("Engineering", "Networking", "Trainings")), - activity("Basic Needs", listOf("Exercise", "Hygiene", "Meals", "Sleep")), - activity("Church", listOf("Service", "Study", "Worship", "Volunteer")), - activity("Activities", listOf("Family", "Friends", "Personal")), - activity("Dates"), - activity("Other"), - ) -} - -fun activity( - name: String, - tags: List = listOf(), -): TimeTrackingActivity { - return TimeTrackingActivity( - name = name, - tags = tags, - color = colorForActivity(name), - iconId = iconForActivity(name), - ) -} - -fun iconForActivity(activityName: String): Int { - val icons = - mapOf( - "Activities" to R.drawable.baseline_people_24, - "Basic Needs" to R.drawable.baseline_bed_24, - "Church" to R.drawable.baseline_church_24, - "Dates" to R.drawable.baseline_heart_broken_24, - "Other" to R.drawable.baseline_horizontal_rule_24, - "Personal Learning" to R.drawable.baseline_computer_24, - "Planning" to R.drawable.baseline_timer_24, - "Professional Development" to R.drawable.baseline_home_work_24, - "TODOs" to R.drawable.baseline_check_box_24, - "Travel" to R.drawable.baseline_directions_car_24, - ) - return icons.getOrDefault(activityName, R.drawable.baseline_question_mark_24) -} - -fun colorForActivity(activityName: String): Color { - val colors = - mapOf( - "Activities" to Color(3, 169, 244, 255), - "Basic Needs" to Color(205, 220, 57, 255), - "Church" to Color(76, 175, 80, 255), - "Dates" to Color(156, 39, 176, 255), - "Other" to Color(96, 125, 139, 255), - "Personal Learning" to Color(255, 193, 7, 255), - "Planning" to Color(255, 87, 34, 255), - "Professional Development" to Color(233, 30, 99, 255), - "TODOs" to Color(245, 54, 57, 255), - "Travel" to Color(120, 82, 72, 255), - ) - return colors.getOrDefault(activityName, Color(96, 125, 139, 255)) -} -- 2.51.2 From 4673dd173d2ecc8e6ec401de14826a5dd9ee1a36 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Mon, 12 Feb 2024 23:24:13 -0700 Subject: [PATCH 13/56] feat: Implement most Wear <-> Mobile methods This commit completes full, end-to-end implementations for nearly all RPC methods. The WearRPCClient and WearRPCServer are effectively complete (all that's left is some error handling that might become needed). As a result, all that's left is the DomainAPI --- .../util/simpletimetracker/wear/DomainAPI.kt | 13 +- .../presentation/MainActivity.kt | 4 +- .../util/simpletimetracker/wearrpc/DTO.kt | 4 +- .../simpletimetracker/wearrpc/Messenger.kt | 15 +- .../wearrpc/WearRPCClient.kt | 42 +-- .../wearrpc/WearRPCServer.kt | 19 +- .../wearrpc/WearRPCServerTest.kt | 107 -------- .../simpletimetracker/wearrpc/WearRPCTest.kt | 251 ++++++++++++++++++ 8 files changed, 301 insertions(+), 154 deletions(-) delete mode 100644 wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt create mode 100644 wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index be9d0ba1..037eaf45 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -34,9 +34,13 @@ class DomainAPI ( } override suspend fun queryCurrentActivities(): Array { - TODO("Not yet implemented") - // 1. Pull the current activities from the runningRecordInteractor - // 2. Return as an `Array` + return runningRecordInteractor.getAll().map { + CurrentActivity( + it.id, + it.timeStarted, + arrayOf() // TODO - Pull actual list of active tags + ) + }.toTypedArray() } override suspend fun setCurrentActivities(activities: Array) { @@ -62,8 +66,7 @@ class DomainAPI ( } override suspend fun querySettings(): Settings { - TODO("Not yet implemented") - // Obtain the desired Settings from the prefsInteractor and return them. + return Settings(prefsInteractor.getAllowMultitasking()) } } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt index 9b803893..7b9c0c5a 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -48,7 +48,7 @@ import androidx.wear.compose.material.rememberScalingLazyListState import androidx.wear.compose.material.scrollAway import com.example.util.simpletimetracker.presentation.theme.SimpleTimeTrackerForWearOSTheme import com.example.util.simpletimetracker.wearrpc.Activity -import com.example.util.simpletimetracker.wearrpc.Messenger +import com.example.util.simpletimetracker.wearrpc.ContextMessenger import com.example.util.simpletimetracker.wearrpc.WearRPCClient import com.google.android.horologist.compose.focus.rememberActiveFocusRequester import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi @@ -97,7 +97,7 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat key1 = queryCount, block = { async(Dispatchers.Default) { - activities = WearRPCClient(Messenger(context)).queryActivities() + activities = WearRPCClient(ContextMessenger(context)).queryActivities() } }, ) diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt index 64012930..be6bc68b 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt @@ -15,7 +15,7 @@ import java.util.Date data class Activity(val id: Long, val name: String, val icon: String, val color: String) -data class CurrentActivity(val id: Long, val name: String, val startedAt: Date, val tags: Array) { +data class CurrentActivity(val id: Long, val startedAt: Long, val tags: Array) { override fun equals(other: Any?): Boolean { // autogenerated if (this === other) return true @@ -24,7 +24,6 @@ data class CurrentActivity(val id: Long, val name: String, val startedAt: Date, other as CurrentActivity if (id != other.id) return false - if (name != other.name) return false if (startedAt != other.startedAt) return false return tags.contentEquals(other.tags) } @@ -32,7 +31,6 @@ data class CurrentActivity(val id: Long, val name: String, val startedAt: Date, override fun hashCode(): Int { // autogenerated var result = id.toInt() - result = 31 * result + name.hashCode() result = 31 * result + startedAt.hashCode() result = 31 * result + tags.contentHashCode() return result diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt index c77dfc75..70fff78d 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt @@ -15,14 +15,17 @@ import com.google.android.gms.wearable.Wearable import kotlinx.coroutines.CompletableDeferred import java.util.concurrent.CancellationException -class Messenger(private val context: Context) { - private val TAG: String = Messenger::class.java.name - - suspend fun sendMessage(capability: String): ByteArray? { - return sendMessage(capability, ByteArray(0)) +interface Messenger { + suspend fun send(capability: String): ByteArray? { + return this.send(capability, ByteArray(0)) } + suspend fun send(capability: String, message: ByteArray): ByteArray? +} + +class ContextMessenger(private val context: Context): Messenger { + private val TAG: String = ContextMessenger::class.java.name - suspend fun sendMessage(capability: String, message: ByteArray): ByteArray? { + override suspend fun send(capability: String, message: ByteArray): ByteArray? { val def = CompletableDeferred() val bestNode = findNearestNode(capability) diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt index 05e5db32..3cbed8bb 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCClient.kt @@ -5,52 +5,54 @@ */ package com.example.util.simpletimetracker.wearrpc -import android.util.Log import com.google.gson.Gson import com.google.gson.reflect.TypeToken -class WearRPCClient(private val messenger: Messenger): SimpleTimeTrackerAPI { - private val TAG: String = WearRPCClient::class.java.name +class WearRPCClient(private val messenger: Messenger) : SimpleTimeTrackerAPI { override suspend fun ping(message: String): String { - val response = messenger.sendMessage(Request.PING, message.toByteArray()) + val response = messenger.send(Request.PING, message.toByteArray()) if (response != null) return String(response) else throw WearRPCException("No response") } override suspend fun queryActivities(): Array { - val response = messenger.sendMessage(Request.QUERY_ACTIVITIES) + val response = messenger.send(Request.QUERY_ACTIVITIES) if (response != null) { - Log.i(TAG, String(response)) val collectionType = object : TypeToken>() {}.type return Gson().fromJson(String(response), collectionType) } else throw WearRPCException("No response") } override suspend fun queryCurrentActivities(): Array { - TODO("Not yet implemented") - // 1. Send a Request.QUERY_CURRENT_ACTIVITIES message - // 2. Parse the resulting JSON bytes and return it + val response = messenger.send(Request.QUERY_CURRENT_ACTIVITIES) + if (response != null) { + val collectionType = object : TypeToken>() {}.type + return Gson().fromJson(String(response), collectionType) + } else throw WearRPCException("No response") } override suspend fun setCurrentActivities(activities: Array) { - TODO("Not yet implemented") - // 1. Serialize the given activities to JSON - // 2. Send a Request.SET_CURRENT_ACTIVITIES message - // 3. Wait for response (successful/error) + messenger.send(Request.SET_CURRENT_ACTIVITIES, Gson().toJson(activities).toByteArray()) } override suspend fun queryTagsForActivity(activityId: Long): Array { - TODO("Not yet implemented") - // 1. Serialize the given activityID to bytes - // 2. Send a Request.QUERY_TAGS_FOR_ACTIVITY message - // 3. Deserialize the JSON response and return. + val response = messenger.send( + Request.QUERY_TAGS_FOR_ACTIVITY, + Gson().toJson(activityId).toByteArray(), + ) + if (response != null) { + val collectionType = object : TypeToken>() {}.type + return Gson().fromJson(String(response), collectionType) + } else throw WearRPCException("No response") } override suspend fun querySettings(): Settings { - TODO("Not yet implemented") - // 1. Send a Request.QUERY_SETTINGS message - // 2. Deserialize the JSON response and return + val response = messenger.send(Request.QUERY_SETTINGS) + if (response != null) { + val jsonType = object : TypeToken() {}.type + return Gson().fromJson(String(response), jsonType) + } else throw WearRPCException("No response") } } \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt index d871e28e..58d58d81 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/WearRPCServer.kt @@ -6,6 +6,7 @@ package com.example.util.simpletimetracker.wearrpc import com.google.gson.Gson +import com.google.gson.reflect.TypeToken class WearRPCServer(private val api: SimpleTimeTrackerAPI) { @@ -25,17 +26,15 @@ class WearRPCServer(private val api: SimpleTimeTrackerAPI) { } private suspend fun onQueryTagsForActivity(request: ByteArray): ByteArray? { - TODO("Not yet implemented") - // 1. Parse the activity id (Long) from the request (throw an exception if not there) - // 2. Ask this.api for the associated tags - // 3. JSON-serialize the tag list into a ByteArray to return as a response. + val activityId = Gson().fromJson(String(request), Long::class.java) + return Gson().toJson(api.queryTagsForActivity(activityId)).toByteArray() } private suspend fun onSetCurrentActivities(request: ByteArray): ByteArray? { - TODO("Not yet implemented") - // 1. Deserialize the JSON CurrentActivity records from the request - // 2. Delegate to this.api - // 3. Return a success message (let unresolvable exceptions from this.api bubble up) + val collectionType = object : TypeToken>() {}.type + val activities: Array = Gson().fromJson(String(request), collectionType) + api.setCurrentActivities(activities) + return ByteArray(0) } private suspend fun onPing(request: ByteArray): ByteArray? { @@ -51,8 +50,6 @@ class WearRPCServer(private val api: SimpleTimeTrackerAPI) { } private suspend fun onQuerySettings(): ByteArray? { - TODO("Not yet implemented") - // 1. Obtain the settings from this.api - // 2. Serialize to JSON and return the bytes + return Gson().toJson(api.querySettings()).toByteArray() } } \ No newline at end of file diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt deleted file mode 100644 index b5e1b77b..00000000 --- a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCServerTest.kt +++ /dev/null @@ -1,107 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ -package com.example.util.simpletimetracker.wearrpc - -import org.junit.Test -import org.junit.Assert.* -import org.junit.Before -import kotlinx.coroutines.test.runTest - - -open class WearRPCServerTestBase { - lateinit var api: MockSimpleTimeTrackerAPI - lateinit var rpc: WearRPCServer - - - @Before fun setup() { - api = MockSimpleTimeTrackerAPI() - rpc = WearRPCServer(api) - api.mockReset() - } - - fun assertResponseEquals(expected: String, response: ByteArray?) { - if (response != null) assertEquals(expected, String(response)) - else fail("response was unexpectedly null") - } -} - -class WearRPCServerTest: WearRPCServerTestBase() { - @Test fun returns_null_for_unsupported_request() = runTest { - val response = rpc.onRequest("/fake/path", "fake data".toByteArray()) - assertNull(response) - } - - @Test(expected = WearRPCException::class) - fun raises_for_invalid_SimpleTimeTracker_request() = runTest { - rpc.onRequest("/stt//GET/fake/path", "fake data".toByteArray()) - } -} - -class PingEndpointTest: WearRPCServerTestBase() { - - @Test fun responds_to_empty_request_with_empty_string() = runTest { - val response = rpc.onRequest(Request.PING, ByteArray(0)) - assertResponseEquals("", response) - } - - @Test fun echoes_response() = runTest { - val response = rpc.onRequest(Request.PING, "Hello World!".toByteArray()) - assertResponseEquals("Hello World!", response) - } -} - -class GetActivitiesTest: WearRPCServerTestBase() { - @Test fun returns_no_activities_when_none_are_available() = runTest { - val response = rpc.onRequest(Request.QUERY_ACTIVITIES, ByteArray(0)) - assertResponseEquals("[]", response) - } - - @Test fun returns_one_activity_when_one_exists() = runTest { - api.mock_queryActivities(arrayOf(Activity(42, "Chores"))) - val response = rpc.onRequest(Request.QUERY_ACTIVITIES, ByteArray(0)) - assertResponseEquals("[{\"id\":42,\"name\":\"Chores\"}]", response) - } - - @Test fun returns_all_existing_activities() = runTest { - api.mock_queryActivities(arrayOf(Activity(13, "Singing"), Activity(24, "Homework"))) - val response = rpc.onRequest(Request.QUERY_ACTIVITIES, ByteArray(0)) - assertResponseEquals("[{\"id\":13,\"name\":\"Singing\"},{\"id\":24,\"name\":\"Homework\"}]", response) - } -} - - -class MockSimpleTimeTrackerAPI: SimpleTimeTrackerAPI { - - lateinit var activities: Array - override suspend fun queryActivities(): Array { - return activities - } - - fun mock_queryActivities(activities: Array) { - this.activities = activities - } - - override suspend fun queryCurrentActivities(): Array { - TODO("Not yet implemented") - } - - override suspend fun setCurrentActivities(activities: Array): Unit { - TODO("Not yet implemented") - } - - override suspend fun queryTagsForActivity(activityId: Long): Array { - TODO("Not yet implemented") - } - - override suspend fun querySettings(): Settings { - TODO("Not yet implemented") - } - - fun mockReset() { - this.activities = arrayOf() - } - -} \ No newline at end of file diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt new file mode 100644 index 00000000..6a27e1af --- /dev/null +++ b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt @@ -0,0 +1,251 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.wearrpc + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + + +open class WearRPCServerTestBase { + lateinit var api: MockSimpleTimeTrackerAPI + lateinit var rpc: WearRPCServer + lateinit var messenger: Messenger + lateinit var client: WearRPCClient + + @Before + fun setup() { + api = MockSimpleTimeTrackerAPI() + rpc = WearRPCServer(api) + messenger = MockMessenger(rpc) + client = WearRPCClient(messenger) + api.mockReset() + } +} + +class WearRPCServerTest : WearRPCServerTestBase() { + @Test + fun returns_null_for_unsupported_request() = runTest { + val response = rpc.onRequest("/fake/path", "fake data".toByteArray()) + assertNull(response) + } + + @Test(expected = WearRPCException::class) + fun raises_for_invalid_SimpleTimeTracker_request() = runTest { + rpc.onRequest("/stt//GET/fake/path", "fake data".toByteArray()) + } +} + +class PingTest : WearRPCServerTestBase() { + + @Test + fun responds_to_empty_request_with_empty_string() = runTest { + val response = client.ping("") + assertEquals("", response) + } + + @Test + fun echoes_response() = runTest { + val response = client.ping("Hello World!") + assertEquals("Hello World!", response) + } +} + +class GetActivitiesTest : WearRPCServerTestBase() { + @Test + fun returns_no_activities_when_none_are_available() = runTest { + val response = client.queryActivities() + assertArrayEquals(arrayOf(), response) + } + + @Test + fun returns_one_activity_when_one_exists() = runTest { + val activities = arrayOf(Activity(42, "Chores", "🎉", "#00FF00")) + api.mock_queryActivities(activities) + val response = client.queryActivities() + assertArrayEquals(activities, response) + } + + @Test + fun returns_all_existing_activities() = runTest { + val activities = arrayOf( + Activity(13, "Singing", "🎶", "#123456"), + Activity(24, "Homework", "📝", "#ABCDEF"), + ) + api.mock_queryActivities(activities) + val response = client.queryActivities() + assertArrayEquals(activities, response) + } +} + +class QueryCurrentActivitiesTest : WearRPCServerTestBase() { + @Test + fun returns_no_activities_when_none_are_running() = runTest { + val activities = arrayOf() + val response = client.queryCurrentActivities() + assertArrayEquals(activities, response) + } + @Test + fun returns_one_activity_when_one_exists() = runTest { + val jan_31_2024_afternoon = 1706704801L + val activities = arrayOf( + CurrentActivity( + 42, + "Activities", + jan_31_2024_afternoon, + arrayOf(Tag(1, "Friends"), Tag(2, "Family")), + ), + ) + api.mock_queryCurrentActivities(activities) + val response = client.queryCurrentActivities() + assertArrayEquals(activities, response) + } + + @Test + fun returns_all_running_activities() = runTest { + val jan_31_2024_afternoon = 1706704801L + val jan_31_2024_evening = 1706751601L + val activities = arrayOf( + CurrentActivity( + 42, + "Activities", + jan_31_2024_afternoon, + arrayOf(Tag(1, "Friends"), Tag(2, "Family")), + ), + CurrentActivity( + 42, + "Chores", + jan_31_2024_evening, + arrayOf(Tag(5, "Shopping")), + ), + ) + api.mock_queryCurrentActivities(activities) + val response = client.queryCurrentActivities() + assertArrayEquals(activities, response) + } +} + +class QueryTagsForActivityTest: WearRPCServerTestBase() { + @Test + fun returns_no_tags_if_activity_has_none() = runTest { + api.mock_queryTagsForActivity(mapOf(13L to arrayOf())) + val response = client.queryTagsForActivity(13) + assertArrayEquals(arrayOf(), response) + } + + @Test + fun returns_no_tags_if_activity_doesnt_exist() = runTest { + val response = client.queryTagsForActivity(42) + assertArrayEquals(arrayOf(), response) + } + + @Test + fun returns_one_tag_associated_with_activity() = runTest { + val tags = arrayOf(Tag(5, "Shopping")) + api.mock_queryTagsForActivity(mapOf(13L to tags)) + val response = client.queryTagsForActivity(13) + assertArrayEquals(tags, response) + } + + @Test + fun returns_all_tags_associated_with_activity() = runTest { + val tags = arrayOf(Tag(5, "Shopping"), Tag(14, "Work")) + api.mock_queryTagsForActivity(mapOf(13L to tags)) + val response = client.queryTagsForActivity(13L) + assertArrayEquals(tags, response) + } + + @Test + fun returns_only_tags_associated_with_requested_activity() = runTest { + val tags = arrayOf(Tag(5, "Shopping"), Tag(14, "Work")) + val otherTags = arrayOf(Tag(7, "Chores"), Tag(13, "Sleep")) + api.mock_queryTagsForActivity(mapOf(10L to tags, 17L to otherTags)) + val response = client.queryTagsForActivity(10L) + assertArrayEquals(tags, response) + val responseOther = client.queryTagsForActivity(17L) + assertArrayEquals(otherTags, responseOther) + } +} + +class QuerySettingsTest: WearRPCServerTestBase() { + @Test + fun returns_settings_with_multitasking_enabled() = runTest { + api.mock_querySettings(Settings(multitasking = true)) + val response = client.querySettings() + assertTrue(response.multitasking) + } + + @Test + fun returns_settings_with_multitasking_disabled() = runTest { + api.mock_querySettings(Settings(multitasking = false)) + val response = client.querySettings() + assertFalse(response.multitasking) + } +} + + +class MockSimpleTimeTrackerAPI : SimpleTimeTrackerAPI { + + var activities: Array = arrayOf() + var currentActivities: Array = arrayOf() + var tags: Map> = mapOf() + lateinit var settings: Settings + + override suspend fun queryActivities(): Array { + return activities + } + + fun mock_queryActivities(activities: Array) { + this.activities = activities + } + + override suspend fun queryCurrentActivities(): Array { + return currentActivities + } + + fun mock_queryCurrentActivities(activities: Array) { + this.currentActivities = activities + } + + override suspend fun setCurrentActivities(activities: Array): Unit { + TODO("Not yet implemented") + } + + override suspend fun queryTagsForActivity(activityId: Long): Array { + return this.tags.getOrDefault(activityId, arrayOf()) + } + fun mock_queryTagsForActivity(tags: Map>) { + this.tags = tags + } + + override suspend fun querySettings(): Settings { + return settings + } + + fun mock_querySettings(settings: Settings) { + this.settings = settings + } + + fun mockReset() { + this.activities = arrayOf() + this.currentActivities = arrayOf() + this.tags = mapOf() + } + +} + +class MockMessenger(private val rpc: WearRPCServer) : Messenger { + + override suspend fun send(capability: String, message: ByteArray): ByteArray? { + return rpc.onRequest(capability, message) + } + +} \ No newline at end of file -- 2.51.2 From a89bfb47a66de3aa2198af64c433540c45ff65e8 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Tue, 13 Feb 2024 00:17:06 -0700 Subject: [PATCH 14/56] feat: Show current activities and duration on Wear The chip for each activity will now render differently if the activity is currently running. closes thehale/SimpleTimeTracker-WearOS#6 --- .../presentation/ActivityChip.kt | 108 ++++++++++++++++++ .../presentation/MainActivity.kt | 72 ++++-------- .../presentation/theme/Color.kt | 4 + 3 files changed, 133 insertions(+), 51 deletions(-) create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt new file mode 100644 index 00000000..153a567c --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt @@ -0,0 +1,108 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation + +import android.widget.Toast +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.Text +import com.example.util.simpletimetracker.presentation.theme.hexCodeToColor +import com.example.util.simpletimetracker.wearrpc.Activity +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun ActivityChip(activity: Activity, startedAt: Long? = null) { + val context = LocalContext.current + val briefIcon = if (activity.icon.startsWith("ic_")) { + "?" + } else { + activity.icon.substring(0, activity.icon.length.coerceAtMost(2)) + } + val color = hexCodeToColor(activity.color) + var modifier = Modifier + .fillMaxWidth(0.9f) + .padding(top = 10.dp) + Chip( + modifier = modifier, + label = { + Text( + text = "$briefIcon : ${activity.name}", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + border = ChipDefaults.chipBorder( + borderStroke = if (startedAt != null) { + BorderStroke(2.dp, Color.White) + } else { + null + }, + ), + secondaryLabel = { + if (startedAt != null) { + Text("Since ${recentTimestampToString(startedAt)}") + } else { + null + } + }, + colors = ChipDefaults.chipColors( + backgroundColor = color, + ), + onClick = { + Toast.makeText( + context, + "Starting `${activity.name}` (id: ${activity.id})\n[Not Yet Implemented]", + Toast.LENGTH_SHORT, + ).show() + }, + ) +} + +fun recentTimestampToString(epochMillis: Long): String { + // Someday, it would be nice for this to show nicer time strings + // e.g. "a few minutes ago", "yesterday", etc. + val time = LocalDateTime.ofInstant( + Instant.ofEpochMilli(epochMillis), + ZoneId.systemDefault(), + ) + if (time > LocalDateTime.ofInstant(Instant.now(), ZoneId.systemDefault()).minusDays(1)) { + return time.format(DateTimeFormatter.ISO_LOCAL_TIME) + } else { + return time.format(DateTimeFormatter.ISO_DATE_TIME).replace("T", " ") + } + +} + +@Preview() +@Composable +fun SampleCooking() { + ActivityChip(Activity(123, "Cooking", "🎉", "#123456")) +} + +@Preview() +@Composable +fun SampleSleep() { + ActivityChip(Activity(456, "Sleeping", "🛏️", "#ABCDEF")) +} + +@Preview() +@Composable +fun CurrentlyRunning() { + ActivityChip(Activity(456, "Sleeping", "🛏️", "#ABCDEF"), startedAt = 1706751601000L) +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt index 7b9c0c5a..fd91cc52 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.selectableGroup import androidx.compose.material.icons.Icons @@ -26,13 +25,8 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.wear.compose.material.Button -import androidx.wear.compose.material.Chip -import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Icon import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.OutlinedButton @@ -40,7 +34,6 @@ import androidx.wear.compose.material.PositionIndicator import androidx.wear.compose.material.Scaffold import androidx.wear.compose.material.ScalingLazyColumn import androidx.wear.compose.material.ScalingLazyListState -import androidx.wear.compose.material.Text import androidx.wear.compose.material.TimeText import androidx.wear.compose.material.Vignette import androidx.wear.compose.material.VignettePosition @@ -49,6 +42,7 @@ import androidx.wear.compose.material.scrollAway import com.example.util.simpletimetracker.presentation.theme.SimpleTimeTrackerForWearOSTheme import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.ContextMessenger +import com.example.util.simpletimetracker.wearrpc.CurrentActivity import com.example.util.simpletimetracker.wearrpc.WearRPCClient import com.google.android.horologist.compose.focus.rememberActiveFocusRequester import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi @@ -91,10 +85,11 @@ fun WearApp() { @Composable fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListState()) { val context = LocalContext.current + var activities: Array by remember { mutableStateOf(arrayOf()) } - var queryCount by remember { mutableIntStateOf(0) } + var activitiesQueryCount by remember { mutableIntStateOf(0) } LaunchedEffect( - key1 = queryCount, + key1 = activitiesQueryCount, block = { async(Dispatchers.Default) { activities = WearRPCClient(ContextMessenger(context)).queryActivities() @@ -102,6 +97,18 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat }, ) + var currentActivities: Array by remember { mutableStateOf(arrayOf()) } + var currentActivitiesQueryCount by remember { mutableIntStateOf(0) } + LaunchedEffect( + key1 = activitiesQueryCount, + block = { + async(Dispatchers.Default) { + currentActivities = + WearRPCClient(ContextMessenger(context)).queryCurrentActivities() + } + }, + ) + val focusRequester = rememberActiveFocusRequester() val rotaryHapticFeedback = rememberRotaryHapticFeedback() ScalingLazyColumn( @@ -115,19 +122,17 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat state = scrollState, ) { for (activity in activities) { + val startedAt = + currentActivities.filter { it.id == activity.id }.getOrNull(0)?.startedAt item { - Activity( - id = activity.id, - name = activity.name, - color = Color(android.graphics.Color.parseColor(activity.color)), - icon = activity.icon, - ) + ActivityChip(activity, startedAt) } } item { OutlinedButton( onClick = { - queryCount++ + activitiesQueryCount++ + currentActivitiesQueryCount++ Toast.makeText(context, "Refreshing activities", Toast.LENGTH_SHORT).show() }, content = { @@ -139,39 +144,4 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat } } -@Composable -fun Activity( - id: Long, - name: String, - color: Color = Color(96, 125, 139, 255), - icon: String = "?", -) { - val context = LocalContext.current - val briefIcon = if (icon.startsWith("ic_")) { - "?" - } else { - icon.substring(0, icon.length.coerceAtMost(2)) - } - Chip( - modifier = Modifier - .fillMaxWidth(0.9f) - .padding(top = 10.dp), - label = { - Text( - text = "$briefIcon : $name", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - colors = ChipDefaults.chipColors( - backgroundColor = color, - ), - onClick = { - Toast.makeText( - context, - "Starting `$name` (id: $id)\n[Not Yet Implemented]", Toast.LENGTH_SHORT, - ).show() - }, - ) -} diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt index 04707998..893b468e 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt @@ -24,3 +24,7 @@ internal val wearColorPalette: Colors = onSecondary = Color.Black, onError = Color.Black, ) + +fun hexCodeToColor(hex: String): Color { + return Color(android.graphics.Color.parseColor(hex)) +} \ No newline at end of file -- 2.51.2 From a7ca31fe5f7eb8fb748250108422e40778f2f274 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Wed, 14 Feb 2024 23:28:16 -0700 Subject: [PATCH 15/56] feat: Query tags for activity on Wear Still just a proof of concept, but tapping an Activity Chip will now create a Toast listing the tags available for the activity. makes progress on thehale/SimpleTimeTracker-WearOS#4 --- .../util/simpletimetracker/wear/DomainAPI.kt | 27 ++++++++++--------- .../simpletimetracker/wear/WearService.kt | 5 ++++ app/src/main/res/values/wear.xml | 2 +- .../presentation/ActivityChip.kt | 10 ++----- .../presentation/MainActivity.kt | 27 ++++++++++++++++--- 5 files changed, 45 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index 037eaf45..9819362b 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -3,9 +3,10 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ - package com.example.util.simpletimetracker.wear +package com.example.util.simpletimetracker.wear import com.example.util.simpletimetracker.domain.interactor.PrefsInteractor +import com.example.util.simpletimetracker.domain.interactor.RecordTagInteractor import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor import com.example.util.simpletimetracker.domain.mapper.AppColorMapper @@ -15,22 +16,21 @@ import com.example.util.simpletimetracker.wearrpc.Settings import com.example.util.simpletimetracker.wearrpc.SimpleTimeTrackerAPI import com.example.util.simpletimetracker.wearrpc.Tag -class DomainAPI ( +class DomainAPI( private val prefsInteractor: PrefsInteractor, private val recordTypeInteractor: RecordTypeInteractor, + private val recordTagInteractor: RecordTagInteractor, private val runningRecordInteractor: RunningRecordInteractor, private val appColorMapper: AppColorMapper, -): SimpleTimeTrackerAPI { +) : SimpleTimeTrackerAPI { override suspend fun queryActivities(): Array { - return recordTypeInteractor.getAll() - .filter { recordType -> !recordType.hidden } + return recordTypeInteractor.getAll().filter { recordType -> !recordType.hidden } .map { recordType -> val color = appColorMapper.mapToColorInt(recordType.color) val hex = String.format("#%06X", (0xFFFFFF and color)) Activity(recordType.id, recordType.name, recordType.icon, hex) - } - .toTypedArray() + }.toTypedArray() } override suspend fun queryCurrentActivities(): Array { @@ -38,7 +38,7 @@ class DomainAPI ( CurrentActivity( it.id, it.timeStarted, - arrayOf() // TODO - Pull actual list of active tags + arrayOf(), // TODO - Pull actual list of active tags ) }.toTypedArray() } @@ -54,15 +54,16 @@ class DomainAPI ( // Activities in the given Array that are not running should be started // For activities in the given Array which are running... - // If the start dates + tags are unchanged, then leave the activity running. - // If the start dates and/or tags are different, stop the current running activity - // instance and restart it as of the requested start date. + // If the start dates + tags are unchanged, then leave the activity running. + // If the start dates and/or tags are different, stop the current running activity + // instance and restart it as of the requested start date. } override suspend fun queryTagsForActivity(activityId: Long): Array { - TODO("Not yet implemented") - // Look up the tags which can be associated with this activity and return them + return recordTagInteractor.getByTypeOrUntyped(activityId).filter { !it.archived }.map { + Tag(id = it.id, name = it.name) + }.toTypedArray() } override suspend fun querySettings(): Settings { diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt index 6ffc92e5..e19af95d 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt @@ -6,6 +6,7 @@ package com.example.util.simpletimetracker.wear import com.example.util.simpletimetracker.domain.interactor.PrefsInteractor +import com.example.util.simpletimetracker.domain.interactor.RecordTagInteractor import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor import com.example.util.simpletimetracker.domain.mapper.AppColorMapper @@ -32,6 +33,9 @@ class WearService : WearableListenerService() { @Inject lateinit var recordTypeInteractor: RecordTypeInteractor + @Inject + lateinit var recordTagInteractor: RecordTagInteractor + @Inject lateinit var runningRecordInteractor: RunningRecordInteractor @@ -43,6 +47,7 @@ class WearService : WearableListenerService() { DomainAPI( prefsInteractor, recordTypeInteractor, + recordTagInteractor, runningRecordInteractor, appColorMapper, ), diff --git a/app/src/main/res/values/wear.xml b/app/src/main/res/values/wear.xml index 9b71c974..db6c1cbd 100644 --- a/app/src/main/res/values/wear.xml +++ b/app/src/main/res/values/wear.xml @@ -9,7 +9,7 @@ /stt//GET/activities /stt//GET/activities/current /stt//POST/activities/current - /stt//GET/activites/:ID/tags + /stt//GET/activities/:ID/tags /stt//GET/settings \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt index 153a567c..2b2f9e34 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt @@ -27,7 +27,7 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter @Composable -fun ActivityChip(activity: Activity, startedAt: Long? = null) { +fun ActivityChip(activity: Activity, startedAt: Long? = null, onClick: () -> Unit = {}) { val context = LocalContext.current val briefIcon = if (activity.icon.startsWith("ic_")) { "?" @@ -64,13 +64,7 @@ fun ActivityChip(activity: Activity, startedAt: Long? = null) { colors = ChipDefaults.chipColors( backgroundColor = color, ), - onClick = { - Toast.makeText( - context, - "Starting `${activity.name}` (id: ${activity.id})\n[Not Yet Implemented]", - Toast.LENGTH_SHORT, - ).show() - }, + onClick = onClick, ) } diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt index fd91cc52..4f32039d 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -23,6 +23,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -50,6 +51,7 @@ import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticFee import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -85,6 +87,8 @@ fun WearApp() { @Composable fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListState()) { val context = LocalContext.current + val composableScope = rememberCoroutineScope() + val rpc = WearRPCClient(ContextMessenger(context)) var activities: Array by remember { mutableStateOf(arrayOf()) } var activitiesQueryCount by remember { mutableIntStateOf(0) } @@ -92,7 +96,7 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat key1 = activitiesQueryCount, block = { async(Dispatchers.Default) { - activities = WearRPCClient(ContextMessenger(context)).queryActivities() + activities = rpc.queryActivities() } }, ) @@ -103,8 +107,7 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat key1 = activitiesQueryCount, block = { async(Dispatchers.Default) { - currentActivities = - WearRPCClient(ContextMessenger(context)).queryCurrentActivities() + currentActivities = rpc.queryCurrentActivities() } }, ) @@ -125,7 +128,23 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat val startedAt = currentActivities.filter { it.id == activity.id }.getOrNull(0)?.startedAt item { - ActivityChip(activity, startedAt) + ActivityChip( + activity, startedAt, + onClick = { + composableScope.launch(Dispatchers.Default){ + val tags = rpc.queryTagsForActivity(activity.id) + composableScope.launch(Dispatchers.Main) { + Toast.makeText( + context, + "`${activity.name}` (id: ${activity.id}) has tags [${ + tags.joinToString(",") { it.name } + }]", + Toast.LENGTH_SHORT, + ).show() + } + } + }, + ) } } item { -- 2.51.2 From d1a0e39b46546b1d3587454e5c2163e6916a411e Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Thu, 15 Feb 2024 00:00:08 -0700 Subject: [PATCH 16/56] feat: Include tags in payload of Current Activities Includes showing the tags on the running activity chip. There is certainly lots of design work to be done for showing the tags on the watch, but for now functionality trumps beauty. --- .../util/simpletimetracker/wear/DomainAPI.kt | 11 +++--- .../presentation/ActivityChip.kt | 35 ++++++++++++++++--- .../presentation/MainActivity.kt | 9 ++--- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index 9819362b..89b3cce5 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -34,11 +34,14 @@ class DomainAPI( } override suspend fun queryCurrentActivities(): Array { - return runningRecordInteractor.getAll().map { + return runningRecordInteractor.getAll().map { record -> CurrentActivity( - it.id, - it.timeStarted, - arrayOf(), // TODO - Pull actual list of active tags + record.id, + record.timeStarted, + record.tagIds.map { tagId -> + val tag = recordTagInteractor.get(tagId) + Tag(id = tag?.id ?: -1, name = tag?.name ?: "") + }.filter { it.id > 0 }.toTypedArray(), ) }.toTypedArray() } diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt index 2b2f9e34..139be4bc 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt @@ -5,14 +5,12 @@ */ package com.example.util.simpletimetracker.presentation -import android.widget.Toast import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -21,19 +19,34 @@ import androidx.wear.compose.material.ChipDefaults import androidx.wear.compose.material.Text import com.example.util.simpletimetracker.presentation.theme.hexCodeToColor import com.example.util.simpletimetracker.wearrpc.Activity +import com.example.util.simpletimetracker.wearrpc.Tag import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId import java.time.format.DateTimeFormatter @Composable -fun ActivityChip(activity: Activity, startedAt: Long? = null, onClick: () -> Unit = {}) { - val context = LocalContext.current +fun ActivityChip( + activity: Activity, + startedAt: Long? = null, + tags: Array = arrayOf(), + onClick: () -> Unit = {}, +) { val briefIcon = if (activity.icon.startsWith("ic_")) { "?" } else { activity.icon.substring(0, activity.icon.length.coerceAtMost(2)) } + val tagsList = if (tags.isNotEmpty()) { + tags.joinToString(", ") { it.name } + } else { + "" + } + val tagString = if (tagsList.length > 0) { + " ($tagsList)" + } else { + "" + } val color = hexCodeToColor(activity.color) var modifier = Modifier .fillMaxWidth(0.9f) @@ -42,7 +55,7 @@ fun ActivityChip(activity: Activity, startedAt: Long? = null, onClick: () -> Uni modifier = modifier, label = { Text( - text = "$briefIcon : ${activity.name}", + text = "$briefIcon : ${activity.name}" + tagString, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -99,4 +112,16 @@ fun SampleSleep() { @Composable fun CurrentlyRunning() { ActivityChip(Activity(456, "Sleeping", "🛏️", "#ABCDEF"), startedAt = 1706751601000L) +} + +@Preview() +@Composable +fun CurrentlyRunningWithTags() { + ActivityChip( + Activity(456, "Sleeping", "🛏️", "#ABCDEF"), startedAt = 1706751601000L, + tags = arrayOf( + Tag(id = 2, name = "Work"), + Tag(id = 4, name = "Hotel"), + ), + ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt index 4f32039d..55935ee6 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -125,13 +125,14 @@ fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListStat state = scrollState, ) { for (activity in activities) { - val startedAt = - currentActivities.filter { it.id == activity.id }.getOrNull(0)?.startedAt + val currentActivity = currentActivities.filter { it.id == activity.id }.getOrNull(0) item { ActivityChip( - activity, startedAt, + activity, + startedAt = currentActivity?.startedAt, + tags = currentActivity?.tags ?: arrayOf(), onClick = { - composableScope.launch(Dispatchers.Default){ + composableScope.launch(Dispatchers.Default) { val tags = rpc.queryTagsForActivity(activity.id) composableScope.launch(Dispatchers.Main) { Toast.makeText( -- 2.51.2 From e337822c200bec59f3d4a266330e61238d21bba5 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sun, 18 Feb 2024 00:51:16 -0700 Subject: [PATCH 17/56] feat: Improve debug logging in `Messenger` Now all connected devices are listed, to help narrow down why messages may not get through to the phone. --- .../example/util/simpletimetracker/wearrpc/Messenger.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt index 70fff78d..843f2dba 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt @@ -56,7 +56,13 @@ class ContextMessenger(private val context: Context): Messenger { private suspend fun findNearestNode(capability: String): Node? { // Find all nodes which support the time tracking message - Log.d(TAG, "Searching for nodes with Simple Time Tracker installed") + Log.d(TAG, "Searching for nodes with ${context.packageName} installed") + val nodeClient = Wearable.getNodeClient(context) + val connectedNodes = Tasks.await(nodeClient.connectedNodes) + connectedNodes.forEach { + Log.d(TAG, "Connected to ${it.displayName} (id: ${it.id}) (nearby: ${it.isNearby})") + } + val capabilityInfo: CapabilityInfo = Tasks.await( Wearable.getCapabilityClient(context) .getCapability(capability, CapabilityClient.FILTER_REACHABLE), -- 2.51.2 From 86ab0ef2426d08fe139a57bcca75ab237e61ca53 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sun, 18 Feb 2024 00:54:26 -0700 Subject: [PATCH 18/56] fix: Remove `name` from `CurrentActivity` in tests The `name` parameter no longer exists on the DTO, so it was breaking the tests. --- .../com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt index 6a27e1af..0f050239 100644 --- a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt +++ b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt @@ -99,7 +99,6 @@ class QueryCurrentActivitiesTest : WearRPCServerTestBase() { val activities = arrayOf( CurrentActivity( 42, - "Activities", jan_31_2024_afternoon, arrayOf(Tag(1, "Friends"), Tag(2, "Family")), ), @@ -116,13 +115,11 @@ class QueryCurrentActivitiesTest : WearRPCServerTestBase() { val activities = arrayOf( CurrentActivity( 42, - "Activities", jan_31_2024_afternoon, arrayOf(Tag(1, "Friends"), Tag(2, "Family")), ), CurrentActivity( 42, - "Chores", jan_31_2024_evening, arrayOf(Tag(5, "Shopping")), ), -- 2.51.2 From c69bc6852d6ced016d4ab1ad69ac57a49dfbf186 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sun, 18 Feb 2024 01:00:06 -0700 Subject: [PATCH 19/56] fix: Correct `map` test in `UnCoveredRangesMapper` The test was failing erroneously due to an incorrectly written Range in the expected output. By making the expected output follow the same format as the other tests, the failing test began passing once again. --- .../domain/mapper/UnCoveredRangesMapperTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/domain/src/test/java/com/example/util/simpletimetracker/domain/mapper/UnCoveredRangesMapperTest.kt b/domain/src/test/java/com/example/util/simpletimetracker/domain/mapper/UnCoveredRangesMapperTest.kt index 6dd13006..38a87153 100644 --- a/domain/src/test/java/com/example/util/simpletimetracker/domain/mapper/UnCoveredRangesMapperTest.kt +++ b/domain/src/test/java/com/example/util/simpletimetracker/domain/mapper/UnCoveredRangesMapperTest.kt @@ -61,7 +61,7 @@ class UnCoveredRangesMapperTest( ), arrayOf( listOf(0L, 10L, listOf(Range(0L, 0L), Range(5L, 5L), Range(10L, 10L))), - listOf(0L to 5L, Range(5L, 10L)), + listOf(Range(0L, 5L), Range(5L, 10L)), ), // Segments on range points -- 2.51.2 From 65d3fe67cf9eed7a7f9faa693f29078345b06951 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sun, 18 Feb 2024 01:06:17 -0700 Subject: [PATCH 20/56] feat(wear): Add navigation layer This is the last big commit for creating the initial, guiding project structure. Replaces the monolithic `MainActivity` with a variety of narrowly focused Composables which assemble nicely into a full application. In particular, this commit shows off the ability to press an activity which opens a new screen with chips for that activity's tags. A major milestone towards #5 --- wear/build.gradle.kts | 7 +- .../presentation/MainActivity.kt | 141 +----------------- .../simpletimetracker/presentation/README.md | 16 ++ .../presentation/components/ActivitiesList.kt | 61 ++++++++ .../presentation/components/README.md | 13 ++ .../presentation/components/RefreshButton.kt | 44 ++++++ .../presentation/components/TagChip.kt | 20 +++ .../presentation/components/TagList.kt | 21 +++ .../presentation/layout/README.md | 9 ++ .../layout/ScaffoldedScrollingColumn.kt | 18 +++ .../presentation/layout/Scaffolding.kt | 36 +++++ .../presentation/layout/ScrollingColumn.kt | 45 ++++++ .../presentation/navigation/MainNavigator.kt | 34 +++++ .../presentation/navigation/README.md | 10 ++ .../presentation/remember/README.md | 16 ++ .../presentation/remember/activities.kt | 50 +++++++ .../remember/currentActivities.kt | 50 +++++++ .../presentation/remember/tags.kt | 51 +++++++ .../presentation/screens/ActivitiesScreen.kt | 37 +++++ .../presentation/screens/README.md | 11 ++ .../presentation/screens/TagsScreen.kt | 21 +++ 21 files changed, 571 insertions(+), 140 deletions(-) create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/README.md create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/components/README.md create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/README.md create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/ScaffoldedScrollingColumn.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/Scaffolding.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/ScrollingColumn.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/README.md create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/README.md create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/README.md create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 1dca9ed1..039e0156 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -58,14 +58,19 @@ android { } dependencies { + var compose_version = "1.3.1" var wear_compose_version = "1.1.0" - // Custom Dependencies + // Runtime Dependencies + implementation("androidx.wear.compose:compose-navigation:$wear_compose_version") implementation("com.google.android.horologist:horologist-compose-layout:0.2.7") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") implementation("androidx.compose.material:material-icons-core:1.6.1") implementation(project(":wearrpc")) + // Dev Dependencies + implementation("androidx.wear:wear-tooling-preview:1.0.0") + // Default Dependencies implementation("androidx.core:core-ktx:1.7.0") implementation("com.google.android.gms:play-services-wearable:17.1.0") diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt index 55935ee6..5a8add39 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -6,52 +6,11 @@ package com.example.util.simpletimetracker.presentation import android.os.Bundle -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Refresh import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.wear.compose.material.Icon -import androidx.wear.compose.material.MaterialTheme -import androidx.wear.compose.material.OutlinedButton -import androidx.wear.compose.material.PositionIndicator -import androidx.wear.compose.material.Scaffold -import androidx.wear.compose.material.ScalingLazyColumn -import androidx.wear.compose.material.ScalingLazyListState -import androidx.wear.compose.material.TimeText -import androidx.wear.compose.material.Vignette -import androidx.wear.compose.material.VignettePosition -import androidx.wear.compose.material.rememberScalingLazyListState -import androidx.wear.compose.material.scrollAway +import com.example.util.simpletimetracker.presentation.navigation.MainNavigator import com.example.util.simpletimetracker.presentation.theme.SimpleTimeTrackerForWearOSTheme -import com.example.util.simpletimetracker.wearrpc.Activity -import com.example.util.simpletimetracker.wearrpc.ContextMessenger -import com.example.util.simpletimetracker.wearrpc.CurrentActivity -import com.example.util.simpletimetracker.wearrpc.WearRPCClient -import com.google.android.horologist.compose.focus.rememberActiveFocusRequester -import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi -import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticFeedback -import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -65,103 +24,7 @@ class MainActivity : ComponentActivity() { @Composable fun WearApp() { SimpleTimeTrackerForWearOSTheme { - val scrollState = rememberScalingLazyListState() - Scaffold( - timeText = { - TimeText(modifier = Modifier.scrollAway(scrollState)) - }, - vignette = { - Vignette(vignettePosition = VignettePosition.TopAndBottom) - }, - positionIndicator = { - PositionIndicator(scalingLazyListState = scrollState) - }, - ) { - ActivityList(scrollState) - } - } -} - - -@OptIn(ExperimentalHorologistComposeLayoutApi::class) -@Composable -fun ActivityList(scrollState: ScalingLazyListState = rememberScalingLazyListState()) { - val context = LocalContext.current - val composableScope = rememberCoroutineScope() - val rpc = WearRPCClient(ContextMessenger(context)) - - var activities: Array by remember { mutableStateOf(arrayOf()) } - var activitiesQueryCount by remember { mutableIntStateOf(0) } - LaunchedEffect( - key1 = activitiesQueryCount, - block = { - async(Dispatchers.Default) { - activities = rpc.queryActivities() - } - }, - ) - - var currentActivities: Array by remember { mutableStateOf(arrayOf()) } - var currentActivitiesQueryCount by remember { mutableIntStateOf(0) } - LaunchedEffect( - key1 = activitiesQueryCount, - block = { - async(Dispatchers.Default) { - currentActivities = rpc.queryCurrentActivities() - } - }, - ) - - val focusRequester = rememberActiveFocusRequester() - val rotaryHapticFeedback = rememberRotaryHapticFeedback() - ScalingLazyColumn( - modifier = Modifier - .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) - .fillMaxSize() - .background(MaterialTheme.colors.background) - .selectableGroup(), - contentPadding = PaddingValues(10.dp), - verticalArrangement = Arrangement.Center, - state = scrollState, - ) { - for (activity in activities) { - val currentActivity = currentActivities.filter { it.id == activity.id }.getOrNull(0) - item { - ActivityChip( - activity, - startedAt = currentActivity?.startedAt, - tags = currentActivity?.tags ?: arrayOf(), - onClick = { - composableScope.launch(Dispatchers.Default) { - val tags = rpc.queryTagsForActivity(activity.id) - composableScope.launch(Dispatchers.Main) { - Toast.makeText( - context, - "`${activity.name}` (id: ${activity.id}) has tags [${ - tags.joinToString(",") { it.name } - }]", - Toast.LENGTH_SHORT, - ).show() - } - } - }, - ) - } - } - item { - OutlinedButton( - onClick = { - activitiesQueryCount++ - currentActivitiesQueryCount++ - Toast.makeText(context, "Refreshing activities", Toast.LENGTH_SHORT).show() - }, - content = { - Icon(Icons.Rounded.Refresh, contentDescription = "Refresh Activities List") - }, - modifier = Modifier.padding(all = 8.dp), - ) - } + MainNavigator() } } - diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/README.md b/wear/src/main/java/com/example/util/simpletimetracker/presentation/README.md new file mode 100644 index 00000000..b349890b --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/README.md @@ -0,0 +1,16 @@ + +# Presentation + +The UI for the app. Responsible for everything the user sees and interacts with. + +## MainActivity + +The `MainActivity` is what gets launched when the user opens the app on his/her +smartwatch. The `MainActivity` typically immediately delegates to a `navigation` +component which then manages which `screen` is first loaded and shown to the +user. + +Each `screen` fetches the data it needs (typically using a `remember` function) +then renders a `layout` containing one or more `components` for the user to interact with. \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt new file mode 100644 index 00000000..888b286d --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -0,0 +1,61 @@ + +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import androidx.wear.tooling.preview.devices.WearDevices +import com.example.util.simpletimetracker.presentation.ActivityChip +import com.example.util.simpletimetracker.presentation.layout.ScaffoldedScrollingColumn +import com.example.util.simpletimetracker.wearrpc.Activity +import com.example.util.simpletimetracker.wearrpc.CurrentActivity + +@Composable +fun ActivitiesList( + activities: Array, + currentActivities: Array, + onSelectActivity: (activity: Activity) -> Unit, + onRefresh: () -> Unit, +) { + ScaffoldedScrollingColumn { + for (activity in activities) { + val currentActivity = currentActivities.filter { it.id == activity.id }.getOrNull(0) + item { + ActivityChip( + activity, + startedAt = currentActivity?.startedAt, + tags = currentActivity?.tags ?: arrayOf(), + onClick = { onSelectActivity(activity) }, + ) + } + } + item { + RefreshButton( + onClick = onRefresh, + contentDescription = "Refresh Activities List", + ) + } + } +} + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun Preview() { + val activities = arrayOf( + Activity(1234, "Chores", "🧹", "#FA0000"), + Activity(4321, "Sleep", "🛏️", "#0000FA"), + ) + val currents = arrayOf( + CurrentActivity(id = 4321, startedAt = 1708241427000L, tags = arrayOf()) + ) + ActivitiesList( + activities, + currentActivities = currents, + onSelectActivity = { /* `it` is the selected activity */ }, + onRefresh = { /* What to do when requesting a refresh */ } + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/README.md b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/README.md new file mode 100644 index 00000000..29f408fa --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/README.md @@ -0,0 +1,13 @@ + +# Components + +Components are "view-only" chunks of UI. In other words, the UI they render +depends only on the values of the parameters passed to them -- no reading data +from any sort of state, local or remote. + +These properties make components extraordinarily easy to test, particularly by +adding `@Preview` versions within the source code file. + - Note `@Preview` versions also render *immediately* in the preview pane, + dramatically improving development velocity. \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt new file mode 100644 index 00000000..76b8a944 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt @@ -0,0 +1,44 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.components + +import android.widget.Toast +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.OutlinedButton + +@Composable +fun RefreshButton(onClick: () -> Unit, contentDescription: String) { + val context = LocalContext.current + + OutlinedButton( + onClick = { + // Idea: Instead of a toast, spin the button while the callback is executing... + Toast.makeText(context, "Refreshing", Toast.LENGTH_SHORT).show() + onClick() + }, + content = { + Icon(Icons.Rounded.Refresh, contentDescription = contentDescription) + }, + modifier = Modifier.padding(all = 8.dp), + ) +} + +@Preview +@Composable +private fun Preview() { + RefreshButton( + onClick = { /* Log.i("Preview", "Refresh Button clicked!") */ }, + contentDescription = "Refreshing...", + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt new file mode 100644 index 00000000..cf20bd0f --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt @@ -0,0 +1,20 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.style.TextOverflow +import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.Text +import com.example.util.simpletimetracker.wearrpc.Tag + +@Composable +fun TagChip(tag: Tag, onClick: () -> Unit) { + Chip( + onClick = onClick, + label = { Text(tag.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt new file mode 100644 index 00000000..34d4eef5 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.components + +import androidx.compose.runtime.Composable +import com.example.util.simpletimetracker.presentation.layout.ScaffoldedScrollingColumn +import com.example.util.simpletimetracker.wearrpc.Tag + +@Composable +fun TagList(tags: Array, onSelectTag: (tag: Tag) -> Unit) { + ScaffoldedScrollingColumn { + for (tag in tags) { + item { + TagChip(tag) { onSelectTag(tag) } + } + } + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/README.md b/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/README.md new file mode 100644 index 00000000..38c19add --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/README.md @@ -0,0 +1,9 @@ + +# Layout + +Structural composables which organize their content composables for painting on +the screen. + +Typically focus on how to position multiple components relative to each other. \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/ScaffoldedScrollingColumn.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/ScaffoldedScrollingColumn.kt new file mode 100644 index 00000000..75fc9ea8 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/ScaffoldedScrollingColumn.kt @@ -0,0 +1,18 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.layout + +import androidx.compose.runtime.Composable +import androidx.wear.compose.material.ScalingLazyListScope +import androidx.wear.compose.material.rememberScalingLazyListState + +@Composable +fun ScaffoldedScrollingColumn(content: ScalingLazyListScope.() -> Unit) { + val scrollState = rememberScalingLazyListState() + Scaffolding(scrollState) { + ScrollingColumn(scrollState, content) + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/Scaffolding.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/Scaffolding.kt new file mode 100644 index 00000000..6cc169f5 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/Scaffolding.kt @@ -0,0 +1,36 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.wear.compose.material.PositionIndicator +import androidx.wear.compose.material.Scaffold +import androidx.wear.compose.material.ScalingLazyListState +import androidx.wear.compose.material.TimeText +import androidx.wear.compose.material.Vignette +import androidx.wear.compose.material.VignettePosition +import androidx.wear.compose.material.rememberScalingLazyListState +import androidx.wear.compose.material.scrollAway + +@Composable +fun Scaffolding( + scrollState: ScalingLazyListState = rememberScalingLazyListState(), + content: @Composable () -> Unit, +) { + Scaffold( + timeText = { + TimeText(modifier = Modifier.scrollAway(scrollState)) + }, + vignette = { + Vignette(vignettePosition = VignettePosition.TopAndBottom) + }, + positionIndicator = { + PositionIndicator(scalingLazyListState = scrollState) + }, + content = content, + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/ScrollingColumn.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/ScrollingColumn.kt new file mode 100644 index 00000000..724666aa --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/layout/ScrollingColumn.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.MaterialTheme +import androidx.wear.compose.material.ScalingLazyColumn +import androidx.wear.compose.material.ScalingLazyListScope +import androidx.wear.compose.material.ScalingLazyListState +import androidx.wear.compose.material.rememberScalingLazyListState +import com.google.android.horologist.compose.focus.rememberActiveFocusRequester +import com.google.android.horologist.compose.navscaffold.ExperimentalHorologistComposeLayoutApi +import com.google.android.horologist.compose.rotaryinput.rememberRotaryHapticFeedback +import com.google.android.horologist.compose.rotaryinput.rotaryWithScroll + +@OptIn(ExperimentalHorologistComposeLayoutApi::class) +@Composable +fun ScrollingColumn( + scrollState: ScalingLazyListState = rememberScalingLazyListState(), + content: ScalingLazyListScope.() -> Unit, +) { + val focusRequester = rememberActiveFocusRequester() + val rotaryHapticFeedback = rememberRotaryHapticFeedback() + ScalingLazyColumn( + modifier = Modifier + .rotaryWithScroll(focusRequester, scrollState, rotaryHapticFeedback) + .fillMaxSize() + .background(MaterialTheme.colors.background) + .selectableGroup(), + contentPadding = PaddingValues(10.dp), + verticalArrangement = Arrangement.Center, + state = scrollState, + content = content, + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt new file mode 100644 index 00000000..65b27906 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt @@ -0,0 +1,34 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.navigation + +import android.util.Log +import androidx.compose.runtime.Composable +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import com.example.util.simpletimetracker.presentation.screens.ActivitiesScreen +import com.example.util.simpletimetracker.presentation.screens.TagsScreen + +@Composable +fun MainNavigator() { + val navController = rememberSwipeDismissableNavController() + SwipeDismissableNavHost( + navController = navController, + startDestination = "activities" + ) { + composable("activities") { + ActivitiesScreen(onSelectActivity = { id -> + navController.navigate("activities/$id/tags") + }) + } + composable("activities/{id}/tags") { + TagsScreen(activityId = it.arguments?.getString("id")?.toLong()!!, onSelectTag = { + navController.navigate("activities") + }) + } + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/README.md b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/README.md new file mode 100644 index 00000000..c09ea6c2 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/README.md @@ -0,0 +1,10 @@ + +# Navigation + +Navigation is responsible for moving users between screens of the app. + +Typically, each screen will take one or more listener lambdas as parameters, to +which Navigation will provide implementations which move the user from one +screen to another. \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/README.md b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/README.md new file mode 100644 index 00000000..82f9c7d0 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/README.md @@ -0,0 +1,16 @@ + +# Remember Functions + +A package of `remember` functions for usage in other Composables. + +> If you have worked with ReactJS, this package will remind you of +[React Hooks](https://react.dev/reference/react/hooks). + +Essentially, these `remember` functions are reusable chunks of logic that can be +plugged into and shared between multiple Composables. + +They contain zero UI/rendering code. Rather they focus on wrapping reasonably +complex chunks of logic (e.g. fetching data from a remote source) for easy +consumption by a UI element. \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt new file mode 100644 index 00000000..a2f12d82 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt @@ -0,0 +1,50 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.remember + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.example.util.simpletimetracker.wearrpc.Activity +import com.example.util.simpletimetracker.wearrpc.WearRPCClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async + +/** + * Handles asynchronous retrieval of the available activities in Simple Time Tracker on the + * connected phone. + * + * Usage: + * ``` + * val rpc = /* create a WearRPCClient instance */ + * val (activities, refresh) = rememberActivities(rpc) + * ``` + * + * Initially, `activities` will be an empty array. Once the actual activities are received + * from the phone, `activities` will *automatically* update to that array and the encapsulating + * Composable will *automatically* re-render. + * + * `refresh` is a function you can call to forcibly re-request the array of available activities + * from the phone. + */ +@Composable +fun rememberActivities(rpc: WearRPCClient): Pair, () -> Unit> { + var activities: Array by remember { mutableStateOf(arrayOf()) } + var activitiesQueryCount by remember { mutableIntStateOf(0) } + LaunchedEffect( + key1 = activitiesQueryCount, + block = { + async(Dispatchers.Default) { + activities = rpc.queryActivities() + } + }, + ) + return Pair(activities) { activitiesQueryCount++ } +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt new file mode 100644 index 00000000..afabf369 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt @@ -0,0 +1,50 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.remember + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.example.util.simpletimetracker.wearrpc.CurrentActivity +import com.example.util.simpletimetracker.wearrpc.WearRPCClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async + +/** + * Handles asynchronous retrieval of the currently running activities in Simple Time Tracker on the + * connected phone. + * + * Usage: + * ``` + * val rpc = /* create a WearRPCClient instance */ + * val (currents, refresh) = rememberCurrentActivities(rpc) + * ``` + * + * Initially, `currents` will be an empty array. Once the actual current activities are received + * from the phone, `currents` will *automatically* update to that array and the encapsulating + * Composable will *automatically* re-render. + * + * `refresh` is a function you can call to forcibly re-request the array of current activities + * from the phone. + */ +@Composable +fun rememberCurrentActivities(rpc: WearRPCClient): Pair, () -> Unit> { + var currentActivities: Array by remember { mutableStateOf(arrayOf()) } + var currentActivitiesQueryCount by remember { mutableIntStateOf(0) } + LaunchedEffect( + key1 = currentActivitiesQueryCount, + block = { + async(Dispatchers.Default) { + currentActivities = rpc.queryCurrentActivities() + } + }, + ) + return Pair(currentActivities) { currentActivitiesQueryCount++ } +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt new file mode 100644 index 00000000..82838957 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt @@ -0,0 +1,51 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.remember + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.example.util.simpletimetracker.wearrpc.Tag +import com.example.util.simpletimetracker.wearrpc.WearRPCClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async + +/** + * Handles asynchronous retrieval of the available tags for a specific activity in + * Simple Time Tracker on the connected phone. + * + * Usage: + * ``` + * val activityId: Long = /* obtain the ID (e.g. as a parameter) */ + * val rpc = /* create a WearRPCClient instance */ + * val (tags, refresh) = rememberTags(rpc, activityId) + * ``` + * + * Initially, `tags` will be an empty array. Once the actual tags are received + * from the phone, `tags` will *automatically* update to that array and the encapsulating + * Composable will *automatically* re-render. + * + * `refresh` is a function you can call to forcibly re-request the array of available tags + * from the phone. + */ +@Composable +fun rememberTags(rpc: WearRPCClient, activityId: Long): Pair, () -> Unit> { + var tags: Array by remember { mutableStateOf(arrayOf()) } + var tagsQueryCount by remember { mutableIntStateOf(0) } + LaunchedEffect( + key1 = tagsQueryCount, + block = { + async(Dispatchers.Default) { + tags = rpc.queryTagsForActivity(activityId) + } + }, + ) + return Pair(tags) { tagsQueryCount++ } +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt new file mode 100644 index 00000000..430a2dbe --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -0,0 +1,37 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.example.util.simpletimetracker.presentation.components.ActivitiesList +import com.example.util.simpletimetracker.presentation.remember.rememberActivities +import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities +import com.example.util.simpletimetracker.wearrpc.ContextMessenger +import com.example.util.simpletimetracker.wearrpc.WearRPCClient + + +@Composable +fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { + val rpc = WearRPCClient(ContextMessenger(LocalContext.current)) + val (activities, refreshActivities) = rememberActivities(rpc) + val (currentActivities, refreshCurrentActivities) = rememberCurrentActivities(rpc) + + ActivitiesList( + activities, + currentActivities, + onSelectActivity = { + onSelectActivity(it.id) + }, + onRefresh = { + refreshActivities() + refreshCurrentActivities() + }, + ) +} + + + diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/README.md b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/README.md new file mode 100644 index 00000000..a39f159d --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/README.md @@ -0,0 +1,11 @@ + +# Screens + +Full-page, interactive user-facing experiences. + +Screens are typically assemble one or more component composables into a UI that +a user finds useful. + - Fetch relevant data, which is then passed to a component for rendering. + - Configure listeners and reactions to button taps/events. \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt new file mode 100644 index 00000000..1e7902f3 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.screens + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.example.util.simpletimetracker.presentation.components.TagList +import com.example.util.simpletimetracker.presentation.remember.rememberTags +import com.example.util.simpletimetracker.wearrpc.ContextMessenger +import com.example.util.simpletimetracker.wearrpc.WearRPCClient + +@Composable +fun TagsScreen(activityId: Long, onSelectTag: () -> Unit) { + val rpc = WearRPCClient(ContextMessenger(LocalContext.current)) + val (tags) = rememberTags(rpc, activityId) + + TagList(tags, onSelectTag = { onSelectTag() } ) +} \ No newline at end of file -- 2.51.2 From 072898348131e984322d63222a277c1f660d30eb Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Mon, 19 Feb 2024 14:22:10 -0700 Subject: [PATCH 21/56] style: Make `LaunchedEffect` calls more "Kotlin-y" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apparently, in Kotlin, when the last parameter to a function is a callback, you can instead specify the callback in a block following the function call. 🤯 The result looks much cleaner. Co-authored-by: kantahrek <152828461+kantahrek@users.noreply.github.com> --- .../presentation/remember/activities.kt | 13 +++++-------- .../presentation/remember/currentActivities.kt | 13 +++++-------- .../simpletimetracker/presentation/remember/tags.kt | 13 +++++-------- 3 files changed, 15 insertions(+), 24 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt index a2f12d82..7bf4b6a5 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt @@ -38,13 +38,10 @@ import kotlinx.coroutines.async fun rememberActivities(rpc: WearRPCClient): Pair, () -> Unit> { var activities: Array by remember { mutableStateOf(arrayOf()) } var activitiesQueryCount by remember { mutableIntStateOf(0) } - LaunchedEffect( - key1 = activitiesQueryCount, - block = { - async(Dispatchers.Default) { - activities = rpc.queryActivities() - } - }, - ) + LaunchedEffect(activitiesQueryCount) { + async(Dispatchers.Default) { + activities = rpc.queryActivities() + } + } return Pair(activities) { activitiesQueryCount++ } } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt index afabf369..d0aafbdf 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt @@ -38,13 +38,10 @@ import kotlinx.coroutines.async fun rememberCurrentActivities(rpc: WearRPCClient): Pair, () -> Unit> { var currentActivities: Array by remember { mutableStateOf(arrayOf()) } var currentActivitiesQueryCount by remember { mutableIntStateOf(0) } - LaunchedEffect( - key1 = currentActivitiesQueryCount, - block = { - async(Dispatchers.Default) { - currentActivities = rpc.queryCurrentActivities() - } - }, - ) + LaunchedEffect(currentActivitiesQueryCount) { + async(Dispatchers.Default) { + currentActivities = rpc.queryCurrentActivities() + } + } return Pair(currentActivities) { currentActivitiesQueryCount++ } } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt index 82838957..ecf0cdcc 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt @@ -39,13 +39,10 @@ import kotlinx.coroutines.async fun rememberTags(rpc: WearRPCClient, activityId: Long): Pair, () -> Unit> { var tags: Array by remember { mutableStateOf(arrayOf()) } var tagsQueryCount by remember { mutableIntStateOf(0) } - LaunchedEffect( - key1 = tagsQueryCount, - block = { - async(Dispatchers.Default) { - tags = rpc.queryTagsForActivity(activityId) - } - }, - ) + LaunchedEffect(tagsQueryCount) { + async(Dispatchers.Default) { + tags = rpc.queryTagsForActivity(activityId) + } + } return Pair(tags) { tagsQueryCount++ } } \ No newline at end of file -- 2.51.2 From 10a919d375adbcdbcc81096b18b6b6bb7ebf959b Mon Sep 17 00:00:00 2001 From: kantahrek <152828461+kantahrek@users.noreply.github.com> Date: Sat, 17 Feb 2024 22:46:16 -0800 Subject: [PATCH 22/56] Converted ActivityChip to a ToggleChip with Switch, wired it in to the current activities list --- .../presentation/ActivityChip.kt | 47 ++++++++++++------- .../presentation/MainActivity.kt | 1 - .../presentation/components/ActivitiesList.kt | 4 +- .../remember/currentActivities.kt | 24 +++++++++- .../presentation/screens/ActivitiesScreen.kt | 13 +++-- 5 files changed, 64 insertions(+), 25 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt index 139be4bc..e3ac39d4 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt @@ -5,18 +5,23 @@ */ package com.example.util.simpletimetracker.presentation -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.wear.compose.material.Chip -import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.Switch import androidx.wear.compose.material.Text +import androidx.wear.compose.material.ToggleChip +import androidx.wear.compose.material.ToggleChipDefaults import com.example.util.simpletimetracker.presentation.theme.hexCodeToColor import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.Tag @@ -30,7 +35,7 @@ fun ActivityChip( activity: Activity, startedAt: Long? = null, tags: Array = arrayOf(), - onClick: () -> Unit = {}, + onClick: (Boolean) -> Unit = {}, ) { val briefIcon = if (activity.icon.startsWith("ic_")) { "?" @@ -51,7 +56,8 @@ fun ActivityChip( var modifier = Modifier .fillMaxWidth(0.9f) .padding(top = 10.dp) - Chip( + var switchChecked by remember { mutableStateOf(startedAt != null) } + ToggleChip( modifier = modifier, label = { Text( @@ -60,13 +66,6 @@ fun ActivityChip( overflow = TextOverflow.Ellipsis, ) }, - border = ChipDefaults.chipBorder( - borderStroke = if (startedAt != null) { - BorderStroke(2.dp, Color.White) - } else { - null - }, - ), secondaryLabel = { if (startedAt != null) { Text("Since ${recentTimestampToString(startedAt)}") @@ -74,10 +73,26 @@ fun ActivityChip( null } }, - colors = ChipDefaults.chipColors( - backgroundColor = color, + colors = ToggleChipDefaults.toggleChipColors( + checkedStartBackgroundColor = color, + checkedEndBackgroundColor = color, + uncheckedToggleControlColor = ToggleChipDefaults.SwitchUncheckedIconColor, ), - onClick = onClick, + onCheckedChange = { + switchChecked = it + onClick(switchChecked) + }, + checked = switchChecked, + toggleControl = { + Switch( + checked = switchChecked, + enabled = true, + modifier = Modifier.semantics { + this.contentDescription = + if (switchChecked) "On" else "Off" + }, + ) + }, ) } diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt index 5a8add39..4cf34277 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -27,4 +27,3 @@ fun WearApp() { MainNavigator() } } - diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt index 888b286d..54cfa0d4 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -19,6 +19,7 @@ fun ActivitiesList( activities: Array, currentActivities: Array, onSelectActivity: (activity: Activity) -> Unit, + onDeselectActivity: (activity: Activity) -> Unit, onRefresh: () -> Unit, ) { ScaffoldedScrollingColumn { @@ -29,7 +30,7 @@ fun ActivitiesList( activity, startedAt = currentActivity?.startedAt, tags = currentActivity?.tags ?: arrayOf(), - onClick = { onSelectActivity(activity) }, + onClick = { if (it) onSelectActivity(activity) else onDeselectActivity(activity) }, ) } } @@ -56,6 +57,7 @@ private fun Preview() { activities, currentActivities = currents, onSelectActivity = { /* `it` is the selected activity */ }, + onDeselectActivity = { /* `it` is the deselected activity */ }, onRefresh = { /* What to do when requesting a refresh */ } ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt index d0aafbdf..4654990f 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt @@ -11,11 +11,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.example.util.simpletimetracker.wearrpc.CurrentActivity import com.example.util.simpletimetracker.wearrpc.WearRPCClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.launch /** * Handles asynchronous retrieval of the currently running activities in Simple Time Tracker on the @@ -35,13 +37,31 @@ import kotlinx.coroutines.async * from the phone. */ @Composable -fun rememberCurrentActivities(rpc: WearRPCClient): Pair, () -> Unit> { +fun rememberCurrentActivities( + rpc: WearRPCClient, +): Triple< + Array, + ((Array) -> (Array)) -> Unit, + () -> Unit, + > { var currentActivities: Array by remember { mutableStateOf(arrayOf()) } var currentActivitiesQueryCount by remember { mutableIntStateOf(0) } + val queryCurrentActivities = { currentActivitiesQueryCount++ } + val coroutineScope = rememberCoroutineScope() LaunchedEffect(currentActivitiesQueryCount) { async(Dispatchers.Default) { currentActivities = rpc.queryCurrentActivities() } } - return Pair(currentActivities) { currentActivitiesQueryCount++ } + + return Triple( + currentActivities, + { affectCurrentActivities -> + currentActivities = affectCurrentActivities(currentActivities) + coroutineScope.launch(Dispatchers.Default) { + rpc.setCurrentActivities(currentActivities) + queryCurrentActivities() + } + }, { queryCurrentActivities() }, + ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt index 430a2dbe..33be4f2f 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -10,15 +10,16 @@ import androidx.compose.ui.platform.LocalContext import com.example.util.simpletimetracker.presentation.components.ActivitiesList import com.example.util.simpletimetracker.presentation.remember.rememberActivities import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities +import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.ContextMessenger import com.example.util.simpletimetracker.wearrpc.WearRPCClient - @Composable fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { val rpc = WearRPCClient(ContextMessenger(LocalContext.current)) val (activities, refreshActivities) = rememberActivities(rpc) - val (currentActivities, refreshCurrentActivities) = rememberCurrentActivities(rpc) + val (currentActivities, affectCurrentActivities, refreshCurrentActivities) = + rememberCurrentActivities(rpc) ActivitiesList( activities, @@ -26,12 +27,14 @@ fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { onSelectActivity = { onSelectActivity(it.id) }, + onDeselectActivity = { deselectedActivity: Activity -> + affectCurrentActivities { + currentActivities.filter { it.id != deselectedActivity.id }.toTypedArray() + } + }, onRefresh = { refreshActivities() refreshCurrentActivities() }, ) } - - - -- 2.51.2 From ca3644286bae9ff86c47dc9681d7e83b728ce266 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Mon, 19 Feb 2024 14:42:41 -0700 Subject: [PATCH 23/56] style: Apply IDE recommended code-cleanups Remove a blank line and use `isNotEmpty` instead of manually checking size. --- .../util/simpletimetracker/presentation/ActivityChip.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt index e3ac39d4..bcd908b8 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt @@ -47,7 +47,7 @@ fun ActivityChip( } else { "" } - val tagString = if (tagsList.length > 0) { + val tagString = if (tagsList.isNotEmpty()) { " ($tagsList)" } else { "" @@ -108,7 +108,6 @@ fun recentTimestampToString(epochMillis: Long): String { } else { return time.format(DateTimeFormatter.ISO_DATE_TIME).replace("T", " ") } - } @Preview() -- 2.51.2 From 23261ab3a321f77ba51f868e69b6353436ba24bf Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Mon, 19 Feb 2024 14:43:34 -0700 Subject: [PATCH 24/56] refactor: Rename `affectCurrentActivities` to `setCurrentActivities` "affect" is too generic of a word that fails to precisely describe the type of change it causes in the system. "set" is more precise and better matches the naming of the underlying WearRPCClient method it eventually calls. Also removed the unnecessary currying of the callback. Now it's (ArrayActivity>) -> Unit instead of (ArrayActivity>) -> (ArrayActivity>) -> Unit --- .../presentation/remember/currentActivities.kt | 16 +++++++++------- .../presentation/screens/ActivitiesScreen.kt | 9 +++++---- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt index 4654990f..fec3d059 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt @@ -26,12 +26,14 @@ import kotlinx.coroutines.launch * Usage: * ``` * val rpc = /* create a WearRPCClient instance */ - * val (currents, refresh) = rememberCurrentActivities(rpc) + * val (currents, setCurrents, refresh) = rememberCurrentActivities(rpc) * ``` * * Initially, `currents` will be an empty array. Once the actual current activities are received * from the phone, `currents` will *automatically* update to that array and the encapsulating * Composable will *automatically* re-render. + * + * `setCurrents` is a function you can call to set the array of current activities on the phone. * * `refresh` is a function you can call to forcibly re-request the array of current activities * from the phone. @@ -41,8 +43,8 @@ fun rememberCurrentActivities( rpc: WearRPCClient, ): Triple< Array, - ((Array) -> (Array)) -> Unit, - () -> Unit, + (Array) -> Unit, + () -> Unit, > { var currentActivities: Array by remember { mutableStateOf(arrayOf()) } var currentActivitiesQueryCount by remember { mutableIntStateOf(0) } @@ -56,12 +58,12 @@ fun rememberCurrentActivities( return Triple( currentActivities, - { affectCurrentActivities -> - currentActivities = affectCurrentActivities(currentActivities) + { updatedCurrentActivities -> coroutineScope.launch(Dispatchers.Default) { - rpc.setCurrentActivities(currentActivities) + rpc.setCurrentActivities(updatedCurrentActivities) queryCurrentActivities() } - }, { queryCurrentActivities() }, + }, + { queryCurrentActivities() }, ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt index 33be4f2f..dc6c5452 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -18,8 +18,9 @@ import com.example.util.simpletimetracker.wearrpc.WearRPCClient fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { val rpc = WearRPCClient(ContextMessenger(LocalContext.current)) val (activities, refreshActivities) = rememberActivities(rpc) - val (currentActivities, affectCurrentActivities, refreshCurrentActivities) = - rememberCurrentActivities(rpc) + val (currentActivities, setCurrentActivities, refreshCurrentActivities) = rememberCurrentActivities( + rpc, + ) ActivitiesList( activities, @@ -28,9 +29,9 @@ fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { onSelectActivity(it.id) }, onDeselectActivity = { deselectedActivity: Activity -> - affectCurrentActivities { + val remainingActivities = currentActivities.filter { it.id != deselectedActivity.id }.toTypedArray() - } + setCurrentActivities(remainingActivities) }, onRefresh = { refreshActivities() -- 2.51.2 From de321da5b5c2f36cdedb1832e43c641ca5ca277b Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Mon, 19 Feb 2024 15:13:48 -0700 Subject: [PATCH 25/56] refactor: Move `ActivityChip` to `components` folder I was waiting for kantahrek's PR to go up to avoid creating undue merge conflicts. Now that we're both at the same version, the refactor can be completed safely. --- .../simpletimetracker/presentation/components/ActivitiesList.kt | 1 - .../presentation/{ => components}/ActivityChip.kt | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) rename wear/src/main/java/com/example/util/simpletimetracker/presentation/{ => components}/ActivityChip.kt (98%) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt index 54cfa0d4..84fc3bf4 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -9,7 +9,6 @@ package com.example.util.simpletimetracker.presentation.components import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import androidx.wear.tooling.preview.devices.WearDevices -import com.example.util.simpletimetracker.presentation.ActivityChip import com.example.util.simpletimetracker.presentation.layout.ScaffoldedScrollingColumn import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.CurrentActivity diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt similarity index 98% rename from wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt rename to wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index bcd908b8..9210face 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -3,7 +3,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -package com.example.util.simpletimetracker.presentation +package com.example.util.simpletimetracker.presentation.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -- 2.51.2 From ebbb5c070d91ca2e4c42cbfdef16b1a0d5c306d1 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Mon, 19 Feb 2024 15:19:56 -0700 Subject: [PATCH 26/56] refactor: Rename `POST` to `PUT` According to MDN, `PUT` is the verb with the semantics for replacing the set of resources at a specific path -- what we are doing for `setCurrentActivities`. This rename reduces semantic confusion even though it has no impacts on runtime behavior. https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT --- app/src/main/res/values/wear.xml | 2 +- .../java/com/example/util/simpletimetracker/wearrpc/Request.kt | 2 +- .../util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values/wear.xml b/app/src/main/res/values/wear.xml index db6c1cbd..16f131b0 100644 --- a/app/src/main/res/values/wear.xml +++ b/app/src/main/res/values/wear.xml @@ -8,7 +8,7 @@ /stt//GET/ping /stt//GET/activities /stt//GET/activities/current - /stt//POST/activities/current + /stt//PUT/activities/current /stt//GET/activities/:ID/tags /stt//GET/settings diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt index b2fe46d7..36b52cd2 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Request.kt @@ -9,7 +9,7 @@ object Request { val PING = "/stt//GET/ping" val QUERY_ACTIVITIES = "/stt//GET/activities" val QUERY_CURRENT_ACTIVITIES = "/stt//GET/activities/current" - val SET_CURRENT_ACTIVITIES = "/stt//POST/activities/current" + val SET_CURRENT_ACTIVITIES = "/stt//PUT/activities/current" val QUERY_TAGS_FOR_ACTIVITY = "/stt//GET/activities/:ID/tags" val QUERY_SETTINGS = "/stt//GET/settings" } \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt index 9ba6bc36..d8f4f632 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/SimpleTimeTrackerAPI.kt @@ -32,7 +32,7 @@ interface SimpleTimeTrackerAPI { suspend fun queryCurrentActivities(): Array /** - * /stt//POST/activities/current + * /stt//PUT/activities/current * * Replaces the currently running activity/activities with the given activities */ -- 2.51.2 From 47556d7cf34e371c346fa955ce59ecf327556ab8 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Mon, 19 Feb 2024 15:34:14 -0700 Subject: [PATCH 27/56] feat: Start activities on the phone via the wear app!! Finally, a full cycle, MVP of the wear app! This commit adds the ability to start activities on the phone from the wear app. Choose an activity and the associated tag, and the phone app will update accordingly. Of course, the app still has plenty of bugs and rough edges, but this is a huge milestone! closes thehale/SimpleTimeTracker-WearOS#5 closes thehale/SimpleTimeTracker-WearOS#8 --- .../util/simpletimetracker/wear/DomainAPI.kt | 28 +++++++++---------- .../presentation/screens/TagsScreen.kt | 18 ++++++++++-- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index 89b3cce5..5bd41707 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -10,6 +10,7 @@ import com.example.util.simpletimetracker.domain.interactor.RecordTagInteractor import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor import com.example.util.simpletimetracker.domain.mapper.AppColorMapper +import com.example.util.simpletimetracker.domain.model.RunningRecord import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.CurrentActivity import com.example.util.simpletimetracker.wearrpc.Settings @@ -47,20 +48,19 @@ class DomainAPI( } override suspend fun setCurrentActivities(activities: Array) { - TODO("Not yet implemented") - // This is a little tricky... The given `activities` should be considered a declarative - // statement of the activities expected to be running upon successful completion of this - // method. - - // Currently running activities not in the given Array should be stopped - - // Activities in the given Array that are not running should be started - - // For activities in the given Array which are running... - // If the start dates + tags are unchanged, then leave the activity running. - // If the start dates and/or tags are different, stop the current running activity - // instance and restart it as of the requested start date. - + val currents = queryCurrentActivities() + val unchanged = currents.filter { c -> activities.any {a -> a == c} } + val stopped = currents.filter { c -> unchanged.none { u -> u == c} } + val started = activities.filter { a -> currents.none { c -> a == c } } + stopped.forEach { runningRecordInteractor.remove(it.id) } + started.forEach { runningRecordInteractor.add( + RunningRecord( + id = it.id, + timeStarted = it.startedAt, + comment = "", + tagIds = it.tags.map { t -> t.id }, + ) + ) } } override suspend fun queryTagsForActivity(activityId: Long): Array { diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt index 1e7902f3..8fb50b44 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt @@ -8,14 +8,28 @@ package com.example.util.simpletimetracker.presentation.screens import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import com.example.util.simpletimetracker.presentation.components.TagList +import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities import com.example.util.simpletimetracker.presentation.remember.rememberTags import com.example.util.simpletimetracker.wearrpc.ContextMessenger +import com.example.util.simpletimetracker.wearrpc.CurrentActivity import com.example.util.simpletimetracker.wearrpc.WearRPCClient +import java.time.Instant @Composable fun TagsScreen(activityId: Long, onSelectTag: () -> Unit) { val rpc = WearRPCClient(ContextMessenger(LocalContext.current)) val (tags) = rememberTags(rpc, activityId) + val (_, setCurrents) = rememberCurrentActivities(rpc) - TagList(tags, onSelectTag = { onSelectTag() } ) -} \ No newline at end of file + TagList( + tags, + onSelectTag = { + setCurrents( + arrayOf( + CurrentActivity(activityId, Instant.now().toEpochMilli(), arrayOf(it)), + ), + ) + onSelectTag() + }, + ) +} -- 2.51.2 From d2f9e87d9d955da1a51dc96de705a47ce25cb62a Mon Sep 17 00:00:00 2001 From: kantahrek <152828461+kantahrek@users.noreply.github.com> Date: Mon, 19 Feb 2024 23:01:27 -0800 Subject: [PATCH 28/56] Keep ToggleChips the same color in the ActivitiesScreen regardless of on/off status --- .../simpletimetracker/presentation/components/ActivityChip.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index 9210face..8f28d993 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -76,7 +76,8 @@ fun ActivityChip( colors = ToggleChipDefaults.toggleChipColors( checkedStartBackgroundColor = color, checkedEndBackgroundColor = color, - uncheckedToggleControlColor = ToggleChipDefaults.SwitchUncheckedIconColor, + uncheckedStartBackgroundColor = color, + uncheckedEndBackgroundColor = color, ), onCheckedChange = { switchChecked = it -- 2.51.2 From 8ae3ebacad47af3eaa70535db49bd2c284020f46 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Tue, 20 Feb 2024 23:54:08 -0700 Subject: [PATCH 29/56] feat: Create `rememberRPCClient` Simplifies the usage of all other `remember` functions. Additionally, it's now easier to obtain an `rpc` instance for direct RPC requests. Eventually, there might even be the option of supplying the same RPC instance to all callers, enabling request batching/pooling, etc. --- .../presentation/remember/activities.kt | 6 +++--- .../presentation/remember/currentActivities.kt | 10 ++++------ .../presentation/remember/rpcClient.kt | 12 ++++++++++++ .../simpletimetracker/presentation/remember/tags.kt | 6 +++--- .../presentation/screens/ActivitiesScreen.kt | 11 +++-------- .../presentation/screens/TagsScreen.kt | 8 ++------ 6 files changed, 27 insertions(+), 26 deletions(-) create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/rpcClient.kt diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt index 7bf4b6a5..ac33fbf4 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/activities.kt @@ -23,8 +23,7 @@ import kotlinx.coroutines.async * * Usage: * ``` - * val rpc = /* create a WearRPCClient instance */ - * val (activities, refresh) = rememberActivities(rpc) + * val (activities, refresh) = rememberActivities() * ``` * * Initially, `activities` will be an empty array. Once the actual activities are received @@ -35,7 +34,8 @@ import kotlinx.coroutines.async * from the phone. */ @Composable -fun rememberActivities(rpc: WearRPCClient): Pair, () -> Unit> { +fun rememberActivities(): Pair, () -> Unit> { + var rpc = rememberRPCClient() var activities: Array by remember { mutableStateOf(arrayOf()) } var activitiesQueryCount by remember { mutableIntStateOf(0) } LaunchedEffect(activitiesQueryCount) { diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt index fec3d059..960c9f7e 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt @@ -25,27 +25,25 @@ import kotlinx.coroutines.launch * * Usage: * ``` - * val rpc = /* create a WearRPCClient instance */ - * val (currents, setCurrents, refresh) = rememberCurrentActivities(rpc) + * val (currents, setCurrents, refresh) = rememberCurrentActivities() * ``` * * Initially, `currents` will be an empty array. Once the actual current activities are received * from the phone, `currents` will *automatically* update to that array and the encapsulating * Composable will *automatically* re-render. - * + * * `setCurrents` is a function you can call to set the array of current activities on the phone. * * `refresh` is a function you can call to forcibly re-request the array of current activities * from the phone. */ @Composable -fun rememberCurrentActivities( - rpc: WearRPCClient, -): Triple< +fun rememberCurrentActivities(): Triple< Array, (Array) -> Unit, () -> Unit, > { + var rpc = rememberRPCClient() var currentActivities: Array by remember { mutableStateOf(arrayOf()) } var currentActivitiesQueryCount by remember { mutableIntStateOf(0) } val queryCurrentActivities = { currentActivitiesQueryCount++ } diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/rpcClient.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/rpcClient.kt new file mode 100644 index 00000000..0d594b1a --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/rpcClient.kt @@ -0,0 +1,12 @@ +package com.example.util.simpletimetracker.presentation.remember + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import com.example.util.simpletimetracker.wearrpc.ContextMessenger +import com.example.util.simpletimetracker.wearrpc.WearRPCClient + +@Composable +fun rememberRPCClient(): WearRPCClient { + val context = LocalContext.current + return WearRPCClient(ContextMessenger(context)) +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt index ecf0cdcc..35c936ee 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/tags.kt @@ -24,8 +24,7 @@ import kotlinx.coroutines.async * Usage: * ``` * val activityId: Long = /* obtain the ID (e.g. as a parameter) */ - * val rpc = /* create a WearRPCClient instance */ - * val (tags, refresh) = rememberTags(rpc, activityId) + * val (tags, refresh) = rememberTags(activityId) * ``` * * Initially, `tags` will be an empty array. Once the actual tags are received @@ -36,7 +35,8 @@ import kotlinx.coroutines.async * from the phone. */ @Composable -fun rememberTags(rpc: WearRPCClient, activityId: Long): Pair, () -> Unit> { +fun rememberTags(activityId: Long): Pair, () -> Unit> { + var rpc = rememberRPCClient() var tags: Array by remember { mutableStateOf(arrayOf()) } var tagsQueryCount by remember { mutableIntStateOf(0) } LaunchedEffect(tagsQueryCount) { diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt index dc6c5452..84a22d35 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -6,21 +6,16 @@ package com.example.util.simpletimetracker.presentation.screens import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import com.example.util.simpletimetracker.presentation.components.ActivitiesList import com.example.util.simpletimetracker.presentation.remember.rememberActivities import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities import com.example.util.simpletimetracker.wearrpc.Activity -import com.example.util.simpletimetracker.wearrpc.ContextMessenger -import com.example.util.simpletimetracker.wearrpc.WearRPCClient + @Composable fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { - val rpc = WearRPCClient(ContextMessenger(LocalContext.current)) - val (activities, refreshActivities) = rememberActivities(rpc) - val (currentActivities, setCurrentActivities, refreshCurrentActivities) = rememberCurrentActivities( - rpc, - ) + val (activities, refreshActivities) = rememberActivities() + val (currentActivities, setCurrentActivities, refreshCurrentActivities) = rememberCurrentActivities() ActivitiesList( activities, diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt index 8fb50b44..837ed846 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt @@ -6,20 +6,16 @@ package com.example.util.simpletimetracker.presentation.screens import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import com.example.util.simpletimetracker.presentation.components.TagList import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities import com.example.util.simpletimetracker.presentation.remember.rememberTags -import com.example.util.simpletimetracker.wearrpc.ContextMessenger import com.example.util.simpletimetracker.wearrpc.CurrentActivity -import com.example.util.simpletimetracker.wearrpc.WearRPCClient import java.time.Instant @Composable fun TagsScreen(activityId: Long, onSelectTag: () -> Unit) { - val rpc = WearRPCClient(ContextMessenger(LocalContext.current)) - val (tags) = rememberTags(rpc, activityId) - val (_, setCurrents) = rememberCurrentActivities(rpc) + val (tags) = rememberTags(activityId) + val (_, setCurrents) = rememberCurrentActivities() TagList( tags, -- 2.51.2 From ea8b6e8842d0c625f71ce5f69b8f913a1c46cffb Mon Sep 17 00:00:00 2001 From: kantahrek <152828461+kantahrek@users.noreply.github.com> Date: Wed, 21 Feb 2024 00:17:25 -0800 Subject: [PATCH 30/56] ActivityChips now SplitToggleChips so tags can be optionally set at enable time --- .../presentation/components/ActivitiesList.kt | 43 +++++++++++++++++-- .../presentation/components/ActivityChip.kt | 29 +++++++++---- .../presentation/screens/ActivitiesScreen.kt | 1 - 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt index 84fc3bf4..365598d3 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -6,12 +6,18 @@ */ package com.example.util.simpletimetracker.presentation.components +import android.widget.Toast import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.wear.tooling.preview.devices.WearDevices import com.example.util.simpletimetracker.presentation.layout.ScaffoldedScrollingColumn +import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.CurrentActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun ActivitiesList( @@ -21,6 +27,10 @@ fun ActivitiesList( onDeselectActivity: (activity: Activity) -> Unit, onRefresh: () -> Unit, ) { + val coroutineScope = rememberCoroutineScope() + val rpcClient = rememberRPCClient() + val context = LocalContext.current + ScaffoldedScrollingColumn { for (activity in activities) { val currentActivity = currentActivities.filter { it.id == activity.id }.getOrNull(0) @@ -29,7 +39,34 @@ fun ActivitiesList( activity, startedAt = currentActivity?.startedAt, tags = currentActivity?.tags ?: arrayOf(), - onClick = { if (it) onSelectActivity(activity) else onDeselectActivity(activity) }, + onSelectActivity = { + coroutineScope.launch(Dispatchers.Default) { + val activityTags = rpcClient.queryTagsForActivity(activity.id) + coroutineScope.launch(Dispatchers.Main) { + if (activityTags.isNotEmpty()) { + onSelectActivity(activity) + } else { + Toast.makeText(context, "Activity has no tags", Toast.LENGTH_SHORT).show() + } + } + } + }, + onSelectActivitySkipTagSelection = { + coroutineScope.launch(Dispatchers.Default) { + rpcClient.setCurrentActivities( + currentActivities.plus( + CurrentActivity( + activity.id, + System.currentTimeMillis(), + arrayOf(), + ), + ), + ) + } + }, + onDeselectActivity = { + onDeselectActivity(activity) + }, ) } } @@ -50,13 +87,13 @@ private fun Preview() { Activity(4321, "Sleep", "🛏️", "#0000FA"), ) val currents = arrayOf( - CurrentActivity(id = 4321, startedAt = 1708241427000L, tags = arrayOf()) + CurrentActivity(id = 4321, startedAt = 1708241427000L, tags = arrayOf()), ) ActivitiesList( activities, currentActivities = currents, onSelectActivity = { /* `it` is the selected activity */ }, onDeselectActivity = { /* `it` is the deselected activity */ }, - onRefresh = { /* What to do when requesting a refresh */ } + onRefresh = { /* What to do when requesting a refresh */ }, ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index 8f28d993..4b34a8ba 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -11,6 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.contentDescription @@ -18,10 +19,11 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.SplitToggleChip import androidx.wear.compose.material.Switch import androidx.wear.compose.material.Text -import androidx.wear.compose.material.ToggleChip import androidx.wear.compose.material.ToggleChipDefaults +import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient import com.example.util.simpletimetracker.presentation.theme.hexCodeToColor import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.Tag @@ -35,8 +37,13 @@ fun ActivityChip( activity: Activity, startedAt: Long? = null, tags: Array = arrayOf(), - onClick: (Boolean) -> Unit = {}, + onSelectActivity: () -> Unit = {}, + onSelectActivitySkipTagSelection: () -> Unit = {}, + onDeselectActivity: () -> Unit = {}, ) { + val coroutineScope = rememberCoroutineScope() + val rpcClient = rememberRPCClient() + val briefIcon = if (activity.icon.startsWith("ic_")) { "?" } else { @@ -57,7 +64,7 @@ fun ActivityChip( .fillMaxWidth(0.9f) .padding(top = 10.dp) var switchChecked by remember { mutableStateOf(startedAt != null) } - ToggleChip( + SplitToggleChip( modifier = modifier, label = { Text( @@ -73,17 +80,21 @@ fun ActivityChip( null } }, - colors = ToggleChipDefaults.toggleChipColors( - checkedStartBackgroundColor = color, - checkedEndBackgroundColor = color, - uncheckedStartBackgroundColor = color, - uncheckedEndBackgroundColor = color, + colors = ToggleChipDefaults.splitToggleChipColors( + backgroundColor = color, ), onCheckedChange = { + if (it) { + onSelectActivitySkipTagSelection() + } else { + onDeselectActivity() + } switchChecked = it - onClick(switchChecked) }, checked = switchChecked, + onClick = { + onSelectActivity() + }, toggleControl = { Switch( checked = switchChecked, diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt index 84a22d35..ba3bfc54 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -11,7 +11,6 @@ import com.example.util.simpletimetracker.presentation.remember.rememberActiviti import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities import com.example.util.simpletimetracker.wearrpc.Activity - @Composable fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { val (activities, refreshActivities) = rememberActivities() -- 2.51.2 From 5f429f6933bdbe57e0cb13b1f2e9a0babb4c181a Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Thu, 22 Feb 2024 00:03:46 -0700 Subject: [PATCH 31/56] feat: Improve coloring to indicate if an Activity is running Sets the toggle overlay to a dark color if the activity is stopped, and uses a light overlay when the activity is running. --- .../presentation/components/ActivityChip.kt | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index 4b34a8ba..e7b62b4e 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -11,9 +11,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -21,9 +21,9 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.wear.compose.material.SplitToggleChip import androidx.wear.compose.material.Switch +import androidx.wear.compose.material.SwitchDefaults import androidx.wear.compose.material.Text import androidx.wear.compose.material.ToggleChipDefaults -import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient import com.example.util.simpletimetracker.presentation.theme.hexCodeToColor import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.Tag @@ -41,9 +41,6 @@ fun ActivityChip( onSelectActivitySkipTagSelection: () -> Unit = {}, onDeselectActivity: () -> Unit = {}, ) { - val coroutineScope = rememberCoroutineScope() - val rpcClient = rememberRPCClient() - val briefIcon = if (activity.icon.startsWith("ic_")) { "?" } else { @@ -82,6 +79,11 @@ fun ActivityChip( }, colors = ToggleChipDefaults.splitToggleChipColors( backgroundColor = color, + splitBackgroundOverlayColor = if (switchChecked) { + Color.White.copy(alpha = .1F) + } else { + Color.Black.copy(alpha = .3F) + }, ), onCheckedChange = { if (it) { @@ -100,9 +102,14 @@ fun ActivityChip( checked = switchChecked, enabled = true, modifier = Modifier.semantics { - this.contentDescription = - if (switchChecked) "On" else "Off" + this.contentDescription = if (switchChecked) "On" else "Off" }, + colors = SwitchDefaults.colors( + checkedThumbColor = Color.White, + checkedTrackColor = Color.White.copy(alpha = 0.5F), + uncheckedThumbColor = Color.White, + uncheckedTrackColor = Color.White.copy(alpha = 0.5F), + ), ) }, ) @@ -134,6 +141,15 @@ fun SampleSleep() { ActivityChip(Activity(456, "Sleeping", "🛏️", "#ABCDEF")) } +@Preview() +@Composable +fun White() { + // TODO handle the look of light colored chips + // Note: A white color is only possible when using the RGB color picker. + // The default color options in the phone app are mostly darker shades. + ActivityChip(Activity(456, "Sleeping", "🛏️", "#FFFFFF")) +} + @Preview() @Composable fun CurrentlyRunning() { -- 2.51.2 From 047c536facfaddfdb077015cc5172060a5a0c9a6 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Thu, 22 Feb 2024 00:36:31 -0700 Subject: [PATCH 32/56] refactor: Hoist all rpc calls to `ActivitiesScreen` Components are more reusable when they have minimal state and/or dependency on outside resources. This commit raises the rpc calls to `ActivitiesScreen` which has the responsibility for managing the data flow and interactions between components. This update also fixes a few bugs: - Tapping an activity chip will start the activity if it doesn't have any tags. - The switches now update automatically after an activity is started or stopped. closes thehale/SimpleTimeTracker-WearOS#12 closes thehale/SimpleTimeTracker-WearOS#13 --- .../presentation/components/ActivitiesList.kt | 47 +++-------------- .../presentation/components/ActivityChip.kt | 17 +++---- .../presentation/screens/ActivitiesScreen.kt | 51 ++++++++++++++++--- 3 files changed, 60 insertions(+), 55 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt index 365598d3..36b7b3b8 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -1,4 +1,3 @@ - /* * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -6,7 +5,6 @@ */ package com.example.util.simpletimetracker.presentation.components -import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext @@ -16,57 +14,27 @@ import com.example.util.simpletimetracker.presentation.layout.ScaffoldedScrollin import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.CurrentActivity -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch @Composable fun ActivitiesList( activities: Array, currentActivities: Array, onSelectActivity: (activity: Activity) -> Unit, - onDeselectActivity: (activity: Activity) -> Unit, + onEnableActivity: (activity: Activity) -> Unit, + onDisableActivity: (activity: Activity) -> Unit, onRefresh: () -> Unit, ) { - val coroutineScope = rememberCoroutineScope() - val rpcClient = rememberRPCClient() - val context = LocalContext.current - ScaffoldedScrollingColumn { for (activity in activities) { val currentActivity = currentActivities.filter { it.id == activity.id }.getOrNull(0) - item { + item(key = activity.id) { ActivityChip( activity, startedAt = currentActivity?.startedAt, tags = currentActivity?.tags ?: arrayOf(), - onSelectActivity = { - coroutineScope.launch(Dispatchers.Default) { - val activityTags = rpcClient.queryTagsForActivity(activity.id) - coroutineScope.launch(Dispatchers.Main) { - if (activityTags.isNotEmpty()) { - onSelectActivity(activity) - } else { - Toast.makeText(context, "Activity has no tags", Toast.LENGTH_SHORT).show() - } - } - } - }, - onSelectActivitySkipTagSelection = { - coroutineScope.launch(Dispatchers.Default) { - rpcClient.setCurrentActivities( - currentActivities.plus( - CurrentActivity( - activity.id, - System.currentTimeMillis(), - arrayOf(), - ), - ), - ) - } - }, - onDeselectActivity = { - onDeselectActivity(activity) - }, + onPress = { onSelectActivity(activity) }, + onToggleOn = { onEnableActivity(activity) }, + onToggleOff = { onDisableActivity(activity) }, ) } } @@ -93,7 +61,8 @@ private fun Preview() { activities, currentActivities = currents, onSelectActivity = { /* `it` is the selected activity */ }, - onDeselectActivity = { /* `it` is the deselected activity */ }, + onEnableActivity = { /* `it` is the enabled activity */ }, + onDisableActivity = { /* `it` is the disabled activity */ }, onRefresh = { /* What to do when requesting a refresh */ }, ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index e7b62b4e..459bdfbd 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -37,9 +37,9 @@ fun ActivityChip( activity: Activity, startedAt: Long? = null, tags: Array = arrayOf(), - onSelectActivity: () -> Unit = {}, - onSelectActivitySkipTagSelection: () -> Unit = {}, - onDeselectActivity: () -> Unit = {}, + onPress: () -> Unit = {}, + onToggleOn: () -> Unit = {}, + onToggleOff: () -> Unit = {}, ) { val briefIcon = if (activity.icon.startsWith("ic_")) { "?" @@ -60,7 +60,7 @@ fun ActivityChip( var modifier = Modifier .fillMaxWidth(0.9f) .padding(top = 10.dp) - var switchChecked by remember { mutableStateOf(startedAt != null) } + var switchChecked = startedAt != null SplitToggleChip( modifier = modifier, label = { @@ -87,16 +87,13 @@ fun ActivityChip( ), onCheckedChange = { if (it) { - onSelectActivitySkipTagSelection() + onToggleOn() } else { - onDeselectActivity() + onToggleOff() } - switchChecked = it }, checked = switchChecked, - onClick = { - onSelectActivity() - }, + onClick = onPress, toggleControl = { Switch( checked = switchChecked, diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt index ba3bfc54..8bd8fda7 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -6,30 +6,69 @@ package com.example.util.simpletimetracker.presentation.screens import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import com.example.util.simpletimetracker.presentation.components.ActivitiesList import com.example.util.simpletimetracker.presentation.remember.rememberActivities import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities +import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient import com.example.util.simpletimetracker.wearrpc.Activity +import com.example.util.simpletimetracker.wearrpc.CurrentActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { + val coroutineScope = rememberCoroutineScope() + val rpc = rememberRPCClient() val (activities, refreshActivities) = rememberActivities() val (currentActivities, setCurrentActivities, refreshCurrentActivities) = rememberCurrentActivities() + val refresh = { + refreshActivities() + refreshCurrentActivities() + } ActivitiesList( activities, currentActivities, onSelectActivity = { - onSelectActivity(it.id) + coroutineScope.launch(Dispatchers.Default) { + val activityTags = rpc.queryTagsForActivity(it.id) + if (activityTags.isNotEmpty()) { + coroutineScope.launch(Dispatchers.Main) { onSelectActivity(it.id) } + } else { + rpc.setCurrentActivities( + arrayOf( + CurrentActivity( + it.id, + System.currentTimeMillis(), + arrayOf(), + ), + ), + ) + refresh() + } + } }, - onDeselectActivity = { deselectedActivity: Activity -> + onEnableActivity = { + coroutineScope.launch(Dispatchers.Default) { + rpc.setCurrentActivities( + arrayOf( + CurrentActivity( + it.id, + System.currentTimeMillis(), + arrayOf(), + ), + ), + ) + refresh() + } + }, + onDisableActivity = { deselectedActivity: Activity -> val remainingActivities = currentActivities.filter { it.id != deselectedActivity.id }.toTypedArray() setCurrentActivities(remainingActivities) + refresh() }, - onRefresh = { - refreshActivities() - refreshCurrentActivities() - }, + onRefresh = refresh, ) } -- 2.51.2 From f7bc8f0784e9b9ef0fffd19647485dd0124144c0 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Thu, 22 Feb 2024 01:00:00 -0700 Subject: [PATCH 33/56] fix: Persist completed activity records The DomainAPI was removing stopped activity records without saving them to the database. Now, the DomainAPI persists completed records as expected :D What's more, it also follows the settings for minimum durations for activity records. --- .../util/simpletimetracker/wear/DomainAPI.kt | 26 +++++++++++-------- .../simpletimetracker/wear/WearService.kt | 5 ++++ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index 5bd41707..b3c87bd0 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -8,6 +8,7 @@ package com.example.util.simpletimetracker.wear import com.example.util.simpletimetracker.domain.interactor.PrefsInteractor import com.example.util.simpletimetracker.domain.interactor.RecordTagInteractor import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor +import com.example.util.simpletimetracker.domain.interactor.RemoveRunningRecordMediator import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor import com.example.util.simpletimetracker.domain.mapper.AppColorMapper import com.example.util.simpletimetracker.domain.model.RunningRecord @@ -22,6 +23,7 @@ class DomainAPI( private val recordTypeInteractor: RecordTypeInteractor, private val recordTagInteractor: RecordTagInteractor, private val runningRecordInteractor: RunningRecordInteractor, + private val removeRunningRecordMediator: RemoveRunningRecordMediator, private val appColorMapper: AppColorMapper, ) : SimpleTimeTrackerAPI { @@ -49,18 +51,20 @@ class DomainAPI( override suspend fun setCurrentActivities(activities: Array) { val currents = queryCurrentActivities() - val unchanged = currents.filter { c -> activities.any {a -> a == c} } - val stopped = currents.filter { c -> unchanged.none { u -> u == c} } + val unchanged = currents.filter { c -> activities.any { a -> a == c } } + val stopped = currents.filter { c -> unchanged.none { u -> u == c } } val started = activities.filter { a -> currents.none { c -> a == c } } - stopped.forEach { runningRecordInteractor.remove(it.id) } - started.forEach { runningRecordInteractor.add( - RunningRecord( - id = it.id, - timeStarted = it.startedAt, - comment = "", - tagIds = it.tags.map { t -> t.id }, - ) - ) } + stopped.forEach { removeRunningRecordMediator.removeWithRecordAdd(asRunningRecord(it)) } + started.forEach { runningRecordInteractor.add(asRunningRecord(it)) } + } + + private fun asRunningRecord(currentActivity: CurrentActivity): RunningRecord { + return RunningRecord( + id = currentActivity.id, + timeStarted = currentActivity.startedAt, + comment = "", + tagIds = currentActivity.tags.map { t -> t.id }, + ) } override suspend fun queryTagsForActivity(activityId: Long): Array { diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt index e19af95d..6a575a29 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/WearService.kt @@ -8,6 +8,7 @@ package com.example.util.simpletimetracker.wear import com.example.util.simpletimetracker.domain.interactor.PrefsInteractor import com.example.util.simpletimetracker.domain.interactor.RecordTagInteractor import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor +import com.example.util.simpletimetracker.domain.interactor.RemoveRunningRecordMediator import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor import com.example.util.simpletimetracker.domain.mapper.AppColorMapper import com.example.util.simpletimetracker.wearrpc.WearRPCServer @@ -39,6 +40,9 @@ class WearService : WearableListenerService() { @Inject lateinit var runningRecordInteractor: RunningRecordInteractor + @Inject + lateinit var removeRunningRecordMediator: RemoveRunningRecordMediator + @Inject lateinit var appColorMapper: AppColorMapper @@ -49,6 +53,7 @@ class WearService : WearableListenerService() { recordTypeInteractor, recordTagInteractor, runningRecordInteractor, + removeRunningRecordMediator, appColorMapper, ), ) -- 2.51.2 From 83cfd398405dcb4e30b1d1b11a2793118210a0e0 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Thu, 22 Feb 2024 01:39:43 -0700 Subject: [PATCH 34/56] feat(wear): Respect multi-tasking setting The WearOS app now follows the phone's multitasking setting. When multi-tasking is disabled, starting a new activity will stop all other running activities. When multi-tasking is enabled, starting/stopping an activity affects only that activity's state. closes thehale/SimpleTimeTracker-WearOS#18 --- .../mediators/CurrentActivitiesMediator.kt | 36 +++++++++++++++++++ .../remember/currentActivities.kt | 22 ++---------- .../presentation/screens/ActivitiesScreen.kt | 33 +++++------------ .../presentation/screens/TagsScreen.kt | 22 ++++++++---- 4 files changed, 62 insertions(+), 51 deletions(-) create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/mediators/CurrentActivitiesMediator.kt diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/mediators/CurrentActivitiesMediator.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/mediators/CurrentActivitiesMediator.kt new file mode 100644 index 00000000..41398315 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/mediators/CurrentActivitiesMediator.kt @@ -0,0 +1,36 @@ +package com.example.util.simpletimetracker.presentation.mediators + +import com.example.util.simpletimetracker.wearrpc.CurrentActivity +import com.example.util.simpletimetracker.wearrpc.Settings +import com.example.util.simpletimetracker.wearrpc.Tag +import com.example.util.simpletimetracker.wearrpc.WearRPCClient + +class CurrentActivitiesMediator( + private val rpc: WearRPCClient, + private val currents: Array, +) { + suspend fun start(activityId: Long) { + start(activityId, arrayOf()) + } + suspend fun start(activityId: Long, tags: Array) { + val newCurrent = CurrentActivity( + id = activityId, + startedAt = System.currentTimeMillis(), + tags = tags + ) + if (settings().multitasking) { + this.rpc.setCurrentActivities(currents.plus(newCurrent)) + } else { + this.rpc.setCurrentActivities(arrayOf(newCurrent)) + } + } + + suspend fun stop(currentId: Long) { + val remaining = currents.filter { it.id != currentId }.toTypedArray() + this.rpc.setCurrentActivities(remaining) + } + private suspend fun settings(): Settings { + return this.rpc.querySettings() + } + +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt index 960c9f7e..9b1a1c9c 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/currentActivities.kt @@ -11,13 +11,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import com.example.util.simpletimetracker.wearrpc.CurrentActivity -import com.example.util.simpletimetracker.wearrpc.WearRPCClient import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.launch /** * Handles asynchronous retrieval of the currently running activities in Simple Time Tracker on the @@ -38,30 +35,15 @@ import kotlinx.coroutines.launch * from the phone. */ @Composable -fun rememberCurrentActivities(): Triple< - Array, - (Array) -> Unit, - () -> Unit, - > { +fun rememberCurrentActivities(): Pair, () -> Unit> { var rpc = rememberRPCClient() var currentActivities: Array by remember { mutableStateOf(arrayOf()) } var currentActivitiesQueryCount by remember { mutableIntStateOf(0) } val queryCurrentActivities = { currentActivitiesQueryCount++ } - val coroutineScope = rememberCoroutineScope() LaunchedEffect(currentActivitiesQueryCount) { async(Dispatchers.Default) { currentActivities = rpc.queryCurrentActivities() } } - - return Triple( - currentActivities, - { updatedCurrentActivities -> - coroutineScope.launch(Dispatchers.Default) { - rpc.setCurrentActivities(updatedCurrentActivities) - queryCurrentActivities() - } - }, - { queryCurrentActivities() }, - ) + return Pair(currentActivities, { queryCurrentActivities() }) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt index 8bd8fda7..b0e271b3 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -8,11 +8,11 @@ package com.example.util.simpletimetracker.presentation.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import com.example.util.simpletimetracker.presentation.components.ActivitiesList +import com.example.util.simpletimetracker.presentation.mediators.CurrentActivitiesMediator import com.example.util.simpletimetracker.presentation.remember.rememberActivities import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient import com.example.util.simpletimetracker.wearrpc.Activity -import com.example.util.simpletimetracker.wearrpc.CurrentActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -21,7 +21,8 @@ fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { val coroutineScope = rememberCoroutineScope() val rpc = rememberRPCClient() val (activities, refreshActivities) = rememberActivities() - val (currentActivities, setCurrentActivities, refreshCurrentActivities) = rememberCurrentActivities() + val (currentActivities, refreshCurrentActivities) = rememberCurrentActivities() + val currentActivitiesMediator = CurrentActivitiesMediator(rpc, currentActivities) val refresh = { refreshActivities() refreshCurrentActivities() @@ -36,38 +37,22 @@ fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { if (activityTags.isNotEmpty()) { coroutineScope.launch(Dispatchers.Main) { onSelectActivity(it.id) } } else { - rpc.setCurrentActivities( - arrayOf( - CurrentActivity( - it.id, - System.currentTimeMillis(), - arrayOf(), - ), - ), - ) + currentActivitiesMediator.start(it.id) refresh() } } }, onEnableActivity = { coroutineScope.launch(Dispatchers.Default) { - rpc.setCurrentActivities( - arrayOf( - CurrentActivity( - it.id, - System.currentTimeMillis(), - arrayOf(), - ), - ), - ) + currentActivitiesMediator.start(it.id) refresh() } }, onDisableActivity = { deselectedActivity: Activity -> - val remainingActivities = - currentActivities.filter { it.id != deselectedActivity.id }.toTypedArray() - setCurrentActivities(remainingActivities) - refresh() + coroutineScope.launch(Dispatchers.Default) { + currentActivitiesMediator.stop(deselectedActivity.id) + refresh() + } }, onRefresh = refresh, ) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt index 837ed846..fdda1ab3 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt @@ -6,26 +6,34 @@ package com.example.util.simpletimetracker.presentation.screens import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import com.example.util.simpletimetracker.presentation.components.TagList +import com.example.util.simpletimetracker.presentation.mediators.CurrentActivitiesMediator import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities +import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient import com.example.util.simpletimetracker.presentation.remember.rememberTags import com.example.util.simpletimetracker.wearrpc.CurrentActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.time.Instant @Composable fun TagsScreen(activityId: Long, onSelectTag: () -> Unit) { + val coroutineScope = rememberCoroutineScope() + val rpc = rememberRPCClient() val (tags) = rememberTags(activityId) - val (_, setCurrents) = rememberCurrentActivities() + val (currentActivities) = rememberCurrentActivities() + val currentActivitiesMediator = CurrentActivitiesMediator(rpc, currentActivities) TagList( tags, onSelectTag = { - setCurrents( - arrayOf( - CurrentActivity(activityId, Instant.now().toEpochMilli(), arrayOf(it)), - ), - ) - onSelectTag() + coroutineScope.launch(Dispatchers.Default) { + currentActivitiesMediator.start(activityId, arrayOf(it)) + coroutineScope.launch(Dispatchers.Main) { + onSelectTag() + } + } }, ) } -- 2.51.2 From 801355ff0d63f2bc1d5b831b893d2f3d4e720f84 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 00:45:01 -0700 Subject: [PATCH 35/56] feat: Share more Settings and Tag fields over RPC Adds the extra fields to the DTO that will be needed to implement full support on the watch for the behavioral settings defined on the phone. --- .../util/simpletimetracker/wear/DomainAPI.kt | 24 +++++++++---- .../presentation/components/ActivityChip.kt | 4 +-- .../mediators/CurrentActivitiesMediator.kt | 2 +- .../util/simpletimetracker/wearrpc/DTO.kt | 11 +++--- .../simpletimetracker/wearrpc/WearRPCTest.kt | 36 +++++++++++-------- 5 files changed, 50 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index b3c87bd0..ed8962c1 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -11,6 +11,7 @@ import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor import com.example.util.simpletimetracker.domain.interactor.RemoveRunningRecordMediator import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor import com.example.util.simpletimetracker.domain.mapper.AppColorMapper +import com.example.util.simpletimetracker.domain.model.RecordTag import com.example.util.simpletimetracker.domain.model.RunningRecord import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.CurrentActivity @@ -42,8 +43,7 @@ class DomainAPI( record.id, record.timeStarted, record.tagIds.map { tagId -> - val tag = recordTagInteractor.get(tagId) - Tag(id = tag?.id ?: -1, name = tag?.name ?: "") + asTag(recordTagInteractor.get(tagId)) }.filter { it.id > 0 }.toTypedArray(), ) }.toTypedArray() @@ -68,13 +68,25 @@ class DomainAPI( } override suspend fun queryTagsForActivity(activityId: Long): Array { - return recordTagInteractor.getByTypeOrUntyped(activityId).filter { !it.archived }.map { - Tag(id = it.id, name = it.name) - }.toTypedArray() + return recordTagInteractor.getByTypeOrUntyped(activityId).filter { !it.archived } + .map { asTag(it) }.toTypedArray() + } + + private fun asTag(recordTag: RecordTag?): Tag { + return Tag( + id = recordTag?.id ?: -1, + name = recordTag?.name ?: "", + isGeneral = recordTag?.typeId == 0L, + ) } override suspend fun querySettings(): Settings { - return Settings(prefsInteractor.getAllowMultitasking()) + return Settings( + allowMultitasking = prefsInteractor.getAllowMultitasking(), + showRecordTagSelection = prefsInteractor.getShowRecordTagSelection(), + recordTagSelectionCloseAfterOne = prefsInteractor.getRecordTagSelectionCloseAfterOne(), + recordTagSelectionEvenForGeneralTags = prefsInteractor.getRecordTagSelectionEvenForGeneralTags(), + ) } } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index 459bdfbd..d896c159 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -159,8 +159,8 @@ fun CurrentlyRunningWithTags() { ActivityChip( Activity(456, "Sleeping", "🛏️", "#ABCDEF"), startedAt = 1706751601000L, tags = arrayOf( - Tag(id = 2, name = "Work"), - Tag(id = 4, name = "Hotel"), + Tag(id = 2, name = "Work", isGeneral = false), + Tag(id = 4, name = "Hotel", isGeneral = false), ), ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/mediators/CurrentActivitiesMediator.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/mediators/CurrentActivitiesMediator.kt index 41398315..efcc746f 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/mediators/CurrentActivitiesMediator.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/mediators/CurrentActivitiesMediator.kt @@ -18,7 +18,7 @@ class CurrentActivitiesMediator( startedAt = System.currentTimeMillis(), tags = tags ) - if (settings().multitasking) { + if (settings().allowMultitasking) { this.rpc.setCurrentActivities(currents.plus(newCurrent)) } else { this.rpc.setCurrentActivities(arrayOf(newCurrent)) diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt index be6bc68b..f3eb3373 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt @@ -11,8 +11,6 @@ package com.example.util.simpletimetracker.wearrpc * Object definitions for records sent between Wear/Mobile */ -import java.util.Date - data class Activity(val id: Long, val name: String, val icon: String, val color: String) data class CurrentActivity(val id: Long, val startedAt: Long, val tags: Array) { @@ -37,6 +35,11 @@ data class CurrentActivity(val id: Long, val startedAt: Long, val tags: Array): Unit { + override suspend fun setCurrentActivities(activities: Array) { TODO("Not yet implemented") } override suspend fun queryTagsForActivity(activityId: Long): Array { return this.tags.getOrDefault(activityId, arrayOf()) } + fun mock_queryTagsForActivity(tags: Map>) { this.tags = tags } -- 2.51.2 From d64828536acf1e1421c5445087a9d7305c951ebf Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 00:45:18 -0700 Subject: [PATCH 36/56] style: Remove unused imports in `ActivitiesList` --- .../presentation/components/ActivitiesList.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt index 36b7b3b8..a37eb678 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -6,12 +6,9 @@ package com.example.util.simpletimetracker.presentation.components import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.wear.tooling.preview.devices.WearDevices import com.example.util.simpletimetracker.presentation.layout.ScaffoldedScrollingColumn -import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.CurrentActivity -- 2.51.2 From 273813939ffb5d6855ea708ad8fa7ca0f8321503 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 00:46:32 -0700 Subject: [PATCH 37/56] chore: Add RPC responses to debug logs Being able to see the exact JSON going back and forth gives precise insight into the root causes of many problems. --- .../java/com/example/util/simpletimetracker/wearrpc/Messenger.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt index 843f2dba..07160b2d 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt @@ -35,6 +35,7 @@ class ContextMessenger(private val context: Context): Messenger { Wearable.getMessageClient(context).sendRequest(bestNode.id, capability, message) .addOnSuccessListener { Log.d(TAG, "Response received for $capability") + Log.d(TAG, "${String(it)}") def.complete(it) }.addOnCanceledListener { val message = "Request $capability to ${bestNode.displayName} was cancelled" -- 2.51.2 From bdf97cd117a9cfaf4a1952076bb31aa76de3401a Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 00:47:06 -0700 Subject: [PATCH 38/56] style: Remove empty line from `build.gradle.kts` --- wear/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 039e0156..f1ae3976 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -58,7 +58,6 @@ android { } dependencies { - var compose_version = "1.3.1" var wear_compose_version = "1.1.0" // Runtime Dependencies -- 2.51.2 From 6a00fcc92f162d9df5132a863cf63198f8714188 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 00:48:34 -0700 Subject: [PATCH 39/56] feat: Create `StartActivityMediator` Handles all the logic for determining whether the watch should start the activity outright, or request tags first. --- .../wearrpc/StartActivityMediator.kt | 34 +++++ .../wearrpc/StartActivityMediatorTest.kt | 120 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediator.kt create mode 100644 wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediatorTest.kt diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediator.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediator.kt new file mode 100644 index 00000000..9032a455 --- /dev/null +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediator.kt @@ -0,0 +1,34 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.wearrpc + +class StartActivityMediator( + private val api: SimpleTimeTrackerAPI, + private val onRequestStartActivity: suspend (activity: Activity) -> Unit, + private val onRequestTagSelection: suspend (activity: Activity) -> Unit, +) { + suspend fun requestStart(activity: Activity) { + val settings = api.querySettings() + if (settings.showRecordTagSelection) { + requestTagSelectionIfNeeded(activity, settings) + } else { + onRequestStartActivity(activity) + } + } + + private suspend fun requestTagSelectionIfNeeded(activity: Activity, settings: Settings) { + val tags = api.queryTagsForActivity(activity.id) + val generalTags = tags.filter { it.isGeneral } + val nonGeneralTags = tags.filter { !it.isGeneral } + val tagSelectionNeeded = + nonGeneralTags.isNotEmpty() || generalTags.isNotEmpty() && settings.recordTagSelectionEvenForGeneralTags + if (tagSelectionNeeded) { + onRequestTagSelection(activity) + } else { + onRequestStartActivity(activity) + } + } +} \ No newline at end of file diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediatorTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediatorTest.kt new file mode 100644 index 00000000..bc8d7360 --- /dev/null +++ b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediatorTest.kt @@ -0,0 +1,120 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.wearrpc + +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.Assert.* + + +open class StartActivityMediatorTestBase { + + protected val api = MockSimpleTimeTrackerAPI() + protected val startCallback = MockMediatorCallback() + protected val requestTagCallback = MockMediatorCallback() + protected val mediator = StartActivityMediator( + api = api, + onRequestStartActivity = startCallback, + onRequestTagSelection = requestTagCallback, + ) + + protected val sampleActivity = Activity(id = 1, name = "Sleep", icon = "🛏️", color = "#123456") + protected val sampleGeneralTag = Tag(id = 13, name = "Sleep", isGeneral = true) + protected val sampleNonGeneralTag = Tag(id = 14, name = "Work", isGeneral = false) + protected val settings = Settings( + allowMultitasking = false, + showRecordTagSelection = false, + recordTagSelectionCloseAfterOne = false, + recordTagSelectionEvenForGeneralTags = false, + ) + + @Before + fun setup() { + api.mockReset() + startCallback.reset() + requestTagCallback.reset() + } +} + +class `Starts Activity When` : StartActivityMediatorTestBase() { + @Test + fun `tag selection disabled`() = runTest { + api.mock_querySettings(settings.copy(showRecordTagSelection = false)) + mediator.requestStart(sampleActivity) + `assert only start callback invoked`() + } + + @Test + fun `tag selection enabled and activity has no tags`() = runTest { + api.mock_querySettings(settings.copy(showRecordTagSelection = true)) + api.mock_queryTagsForActivity(mapOf()) + mediator.requestStart(sampleActivity) + `assert only start callback invoked`() + } + + @Test + fun `tag selection enabled, but not for generals alone, and activity has only general tags`() = runTest { + api.mock_querySettings(settings.copy(showRecordTagSelection = true, recordTagSelectionEvenForGeneralTags = false)) + api.mock_queryTagsForActivity(mapOf(sampleActivity.id to arrayOf(sampleGeneralTag))) + mediator.requestStart(sampleActivity) + `assert only start callback invoked`() + } + + private fun `assert only start callback invoked`() { + startCallback.assertCalledWith(sampleActivity) + startCallback.assertCallsMade(1) + requestTagCallback.assertCallsMade(0) + } +} + +class `Requests tags when tag selection enabled and`: StartActivityMediatorTestBase() { + private val sampleSettings = settings.copy(showRecordTagSelection = true) + + @Test + fun `activity has non-general tags`() = runTest { + api.mock_querySettings(sampleSettings) + api.mock_queryTagsForActivity(mapOf(sampleActivity.id to arrayOf(sampleNonGeneralTag))) + mediator.requestStart(sampleActivity) + `assert only tag callback invoked`() + } + + @Test + fun `activity has only general tags and tag selection enabled for only generals`() = runTest { + api.mock_querySettings(sampleSettings.copy(recordTagSelectionEvenForGeneralTags = true)) + api.mock_queryTagsForActivity(mapOf(sampleActivity.id to arrayOf(sampleGeneralTag))) + mediator.requestStart(sampleActivity) + `assert only tag callback invoked`() + } + + private fun `assert only tag callback invoked`() { + requestTagCallback.assertCalledWith(sampleActivity) + requestTagCallback.assertCallsMade(1) + startCallback.assertCallsMade(0) + } +} + +class MockMediatorCallback: (Activity) -> Unit { + private var calledWith: Activity? = null + private var callCount: Int = 0 + override fun invoke(activity: Activity) { + calledWith = activity + callCount++ + } + + fun assertCalledWith(activity: Activity) { + assertEquals(activity, calledWith) + } + + fun assertCallsMade(count: Int) { + assertEquals(count, callCount) + } + + fun reset() { + calledWith = null + callCount = 0 + } +} \ No newline at end of file -- 2.51.2 From b1ea192bc74b98b39f22da584d99ccd899fda5bc Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 00:52:03 -0700 Subject: [PATCH 40/56] feat: Support settings controlling tag picking The phone app has several settings controlling the behavior of when to show a tag picker. This commit implements support for the following settings - Whether or not to show a tag picker at all - If a tag picker is allowed, only show it for activities which have at least one non-general tag. closes thehale/SimpleTimeTracker-WearOS#15 --- .../presentation/navigation/MainNavigator.kt | 4 +- .../presentation/screens/ActivitiesScreen.kt | 56 ++++++++++++------- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt index 65b27906..6a8adc25 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt @@ -21,9 +21,7 @@ fun MainNavigator() { startDestination = "activities" ) { composable("activities") { - ActivitiesScreen(onSelectActivity = { id -> - navController.navigate("activities/$id/tags") - }) + ActivitiesScreen(navController) } composable("activities/{id}/tags") { TagsScreen(activityId = it.arguments?.getString("id")?.toLong()!!, onSelectTag = { diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt index b0e271b3..ccfbc69d 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -5,55 +5,69 @@ */ package com.example.util.simpletimetracker.presentation.screens +import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.NavController import com.example.util.simpletimetracker.presentation.components.ActivitiesList import com.example.util.simpletimetracker.presentation.mediators.CurrentActivitiesMediator import com.example.util.simpletimetracker.presentation.remember.rememberActivities import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient import com.example.util.simpletimetracker.wearrpc.Activity +import com.example.util.simpletimetracker.wearrpc.StartActivityMediator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun ActivitiesScreen(onSelectActivity: (activityId: Long) -> Unit) { +fun ActivitiesScreen(navigation: NavController) { val coroutineScope = rememberCoroutineScope() val rpc = rememberRPCClient() val (activities, refreshActivities) = rememberActivities() val (currentActivities, refreshCurrentActivities) = rememberCurrentActivities() - val currentActivitiesMediator = CurrentActivitiesMediator(rpc, currentActivities) val refresh = { refreshActivities() refreshCurrentActivities() } + val currentActivitiesMediator = CurrentActivitiesMediator(rpc, currentActivities) + val startActivityWithoutTags: (Activity) -> Unit = { + Log.d("ActivitiesScreen", "Starting ${it.name} (#${it.id}) without tags") + coroutineScope.launch(Dispatchers.Default) { + currentActivitiesMediator.start(it.id) + refresh() + } + } + val startActivityWithTags: (Activity) -> Unit = { + coroutineScope.launch(Dispatchers.Main) { + Log.d("ActivitiesScreen", "Starting ${it.name} (#${it.id}) with tags") + navigation.navigate("activities/${it.id}/tags") + } + } + val stopActivity: (Activity) -> Unit = { + Log.d("ActivitiesScreen", "Stopping ${it.name} (#${it.id})") + coroutineScope.launch(Dispatchers.Default) { + currentActivitiesMediator.stop(it.id) + refresh() + } + } + + val startActivitiesMediator = StartActivityMediator( + rpc, + onRequestStartActivity = startActivityWithoutTags, + onRequestTagSelection = startActivityWithTags, + ) + ActivitiesList( activities, currentActivities, onSelectActivity = { coroutineScope.launch(Dispatchers.Default) { - val activityTags = rpc.queryTagsForActivity(it.id) - if (activityTags.isNotEmpty()) { - coroutineScope.launch(Dispatchers.Main) { onSelectActivity(it.id) } - } else { - currentActivitiesMediator.start(it.id) - refresh() - } - } - }, - onEnableActivity = { - coroutineScope.launch(Dispatchers.Default) { - currentActivitiesMediator.start(it.id) - refresh() - } - }, - onDisableActivity = { deselectedActivity: Activity -> - coroutineScope.launch(Dispatchers.Default) { - currentActivitiesMediator.stop(deselectedActivity.id) - refresh() + startActivitiesMediator.requestStart(it) } }, + onEnableActivity = startActivityWithoutTags, + onDisableActivity = stopActivity, onRefresh = refresh, ) } -- 2.51.2 From 08c4bea6288d9b4314735a44f3b93c0eba64d2c0 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 01:01:32 -0700 Subject: [PATCH 41/56] refactor: Push `navigation` into `TagsScreen` for consistency While adding settings support to the ActivitiesScreen, it became obvious that screens need access to a navigation controller to avoid cluttering the `MainNavigation` with business logic. This commit pushes the NavController into the TagsScreen for consistency. --- .../presentation/navigation/MainNavigator.kt | 10 +++++----- .../presentation/navigation/README.md | 8 ++++---- .../simpletimetracker/presentation/screens/README.md | 3 ++- .../presentation/screens/TagsScreen.kt | 5 +++-- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt index 6a8adc25..be5ccc21 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt @@ -5,7 +5,6 @@ */ package com.example.util.simpletimetracker.presentation.navigation -import android.util.Log import androidx.compose.runtime.Composable import androidx.wear.compose.navigation.SwipeDismissableNavHost import androidx.wear.compose.navigation.composable @@ -18,15 +17,16 @@ fun MainNavigator() { val navController = rememberSwipeDismissableNavController() SwipeDismissableNavHost( navController = navController, - startDestination = "activities" + startDestination = "activities", ) { composable("activities") { ActivitiesScreen(navController) } composable("activities/{id}/tags") { - TagsScreen(activityId = it.arguments?.getString("id")?.toLong()!!, onSelectTag = { - navController.navigate("activities") - }) + TagsScreen( + activityId = it.arguments?.getString("id")?.toLong()!!, + navigation = navController, + ) } } } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/README.md b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/README.md index c09ea6c2..2e65e5c3 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/README.md +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/README.md @@ -3,8 +3,8 @@ - file, You can obtain one at https://mozilla.org/MPL/2.0/. --> # Navigation -Navigation is responsible for moving users between screens of the app. +Navigation is responsible for defining the screens available in the app and marshalling the correct +parameters to each requested screen. -Typically, each screen will take one or more listener lambdas as parameters, to -which Navigation will provide implementations which move the user from one -screen to another. \ No newline at end of file +Typically, each screen will accept a `navigation: NavController` parameter so it can decide when to +send the user to another screen. \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/README.md b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/README.md index a39f159d..19382e71 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/README.md +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/README.md @@ -8,4 +8,5 @@ Full-page, interactive user-facing experiences. Screens are typically assemble one or more component composables into a UI that a user finds useful. - Fetch relevant data, which is then passed to a component for rendering. - - Configure listeners and reactions to button taps/events. \ No newline at end of file + - Configure listeners and reactions to button taps/events. + - Initiate navigation to other screens \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt index fdda1ab3..5ebf4086 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt @@ -7,6 +7,7 @@ package com.example.util.simpletimetracker.presentation.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope +import androidx.navigation.NavController import com.example.util.simpletimetracker.presentation.components.TagList import com.example.util.simpletimetracker.presentation.mediators.CurrentActivitiesMediator import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities @@ -18,7 +19,7 @@ import kotlinx.coroutines.launch import java.time.Instant @Composable -fun TagsScreen(activityId: Long, onSelectTag: () -> Unit) { +fun TagsScreen(activityId: Long, navigation: NavController) { val coroutineScope = rememberCoroutineScope() val rpc = rememberRPCClient() val (tags) = rememberTags(activityId) @@ -31,7 +32,7 @@ fun TagsScreen(activityId: Long, onSelectTag: () -> Unit) { coroutineScope.launch(Dispatchers.Default) { currentActivitiesMediator.start(activityId, arrayOf(it)) coroutineScope.launch(Dispatchers.Main) { - onSelectTag() + navigation.navigate("activities") } } }, -- 2.51.2 From 03ee922a7c55900bcd5cbb5a04748f72dc8abd73 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 23:07:18 -0700 Subject: [PATCH 42/56] refactor(RefreshButton): Add default `contentDescription` Means calling Composables only need to provide a click handler. --- .../presentation/components/ActivitiesList.kt | 7 +------ .../presentation/components/RefreshButton.kt | 3 +-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt index a37eb678..e7fd7b2d 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -35,12 +35,7 @@ fun ActivitiesList( ) } } - item { - RefreshButton( - onClick = onRefresh, - contentDescription = "Refresh Activities List", - ) - } + item { RefreshButton(onClick = onRefresh) } } } diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt index 76b8a944..b034fcb9 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt @@ -18,7 +18,7 @@ import androidx.wear.compose.material.Icon import androidx.wear.compose.material.OutlinedButton @Composable -fun RefreshButton(onClick: () -> Unit, contentDescription: String) { +fun RefreshButton(onClick: () -> Unit, contentDescription: String = "Refresh") { val context = LocalContext.current OutlinedButton( @@ -39,6 +39,5 @@ fun RefreshButton(onClick: () -> Unit, contentDescription: String) { private fun Preview() { RefreshButton( onClick = { /* Log.i("Preview", "Refresh Button clicked!") */ }, - contentDescription = "Refreshing...", ) } \ No newline at end of file -- 2.51.2 From fa10574bdd30fa4a5f5b91d3e0c2129c51a8cde2 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 23:08:10 -0700 Subject: [PATCH 43/56] fix(ActivityChip): Render Previews on a WearOS screen --- .../presentation/components/ActivityChip.kt | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index d896c159..e232f726 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -8,10 +8,6 @@ package com.example.util.simpletimetracker.presentation.components import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.semantics.contentDescription @@ -24,6 +20,7 @@ import androidx.wear.compose.material.Switch import androidx.wear.compose.material.SwitchDefaults import androidx.wear.compose.material.Text import androidx.wear.compose.material.ToggleChipDefaults +import androidx.wear.tooling.preview.devices.WearDevices import com.example.util.simpletimetracker.presentation.theme.hexCodeToColor import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.Tag @@ -57,12 +54,11 @@ fun ActivityChip( "" } val color = hexCodeToColor(activity.color) - var modifier = Modifier - .fillMaxWidth(0.9f) - .padding(top = 10.dp) var switchChecked = startedAt != null SplitToggleChip( - modifier = modifier, + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(top = 10.dp), label = { Text( text = "$briefIcon : ${activity.name}" + tagString, @@ -126,19 +122,19 @@ fun recentTimestampToString(epochMillis: Long): String { } } -@Preview() +@Preview(device = WearDevices.LARGE_ROUND) @Composable fun SampleCooking() { ActivityChip(Activity(123, "Cooking", "🎉", "#123456")) } -@Preview() +@Preview(device = WearDevices.LARGE_ROUND) @Composable fun SampleSleep() { ActivityChip(Activity(456, "Sleeping", "🛏️", "#ABCDEF")) } -@Preview() +@Preview(device = WearDevices.LARGE_ROUND) @Composable fun White() { // TODO handle the look of light colored chips @@ -147,13 +143,13 @@ fun White() { ActivityChip(Activity(456, "Sleeping", "🛏️", "#FFFFFF")) } -@Preview() +@Preview(device = WearDevices.LARGE_ROUND) @Composable fun CurrentlyRunning() { ActivityChip(Activity(456, "Sleeping", "🛏️", "#ABCDEF"), startedAt = 1706751601000L) } -@Preview() +@Preview(device = WearDevices.LARGE_ROUND) @Composable fun CurrentlyRunningWithTags() { ActivityChip( -- 2.51.2 From 4c19b30431d3ee8bb6ed0bea062422c694ce90a4 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 23:13:08 -0700 Subject: [PATCH 44/56] feat: Support tag selection mode setting On the phone, the user can request for activities to be started immediately after picking one tag, or after picking multiple. With this commit, that behavior is reflected in the WearOS app. Additionally, the new Submit button on the TagList allows users to start the activity from the TagsScreen, but without picking any tags, so it further satisfies thehale/SimpleTimeTracker-WearOS#13 closes thehale/SimpleTimeTracker-WearOS#16 closes thehale/SimpleTimeTracker-WearOS#17 --- .../presentation/components/SubmitButton.kt | 35 ++++++++++ .../presentation/components/TagChip.kt | 69 ++++++++++++++++++- .../presentation/components/TagList.kt | 68 ++++++++++++++++-- .../presentation/remember/settings.kt | 45 ++++++++++++ .../presentation/screens/TagsScreen.kt | 14 ++-- 5 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/components/SubmitButton.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/settings.kt diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/SubmitButton.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/SubmitButton.kt new file mode 100644 index 00000000..36b5864f --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/SubmitButton.kt @@ -0,0 +1,35 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.OutlinedButton + +@Composable +fun SubmitButton(onClick: () -> Unit, contentDescription: String = "Submit") { + OutlinedButton( + onClick = onClick, + content = { + Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = contentDescription) + }, + modifier = Modifier.padding(all = 8.dp), + ) +} + +@Preview +@Composable +private fun Preview() { + SubmitButton( + onClick = { /* Log.i("Preview", "Refresh Button clicked!") */ }, + ) +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt index cf20bd0f..a2b6c850 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt @@ -5,16 +5,83 @@ */ package com.example.util.simpletimetracker.presentation.components +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Checkbox import androidx.wear.compose.material.Chip import androidx.wear.compose.material.Text +import androidx.wear.compose.material.ToggleChip +import androidx.wear.tooling.preview.devices.WearDevices import com.example.util.simpletimetracker.wearrpc.Tag +enum class TagSelectionMode { + SINGLE, MULTI, +} + +@Composable +fun TagChip(tag: Tag, onClick: () -> Unit, mode: TagSelectionMode = TagSelectionMode.SINGLE) { + when (mode) { + TagSelectionMode.SINGLE -> { + SingleSelectTagChip(tag = tag, onClick = onClick) + } + + TagSelectionMode.MULTI -> { + MultiSelectTagChip(tag = tag, onClick = onClick) + } + } + +} + @Composable -fun TagChip(tag: Tag, onClick: () -> Unit) { +private fun SingleSelectTagChip(tag: Tag, onClick: () -> Unit) { Chip( onClick = onClick, label = { Text(tag.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(top = 10.dp), + ) +} + +@Composable +private fun MultiSelectTagChip(tag: Tag, onClick: () -> Unit) { + var checked by remember { mutableStateOf(false) } + ToggleChip( + checked = checked, + onCheckedChange = { + checked = !checked + onClick() + }, + label = { Text(tag.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + toggleControl = { Checkbox(checked = checked) }, + modifier = Modifier + .fillMaxWidth(0.9f) + .padding(top = 10.dp) + ) +} + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun Default() { + TagChip(tag = Tag(id = 123, name = "Sleep", isGeneral = false), onClick = {}) +} + + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun MultiSelectMode() { + TagChip( + tag = Tag(id = 123, name = "Sleep", isGeneral = false), + onClick = {}, + mode = TagSelectionMode.MULTI, ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt index 34d4eef5..e3b358c3 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt @@ -5,17 +5,77 @@ */ package com.example.util.simpletimetracker.presentation.components +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Text +import androidx.wear.tooling.preview.devices.WearDevices import com.example.util.simpletimetracker.presentation.layout.ScaffoldedScrollingColumn import com.example.util.simpletimetracker.wearrpc.Tag @Composable -fun TagList(tags: Array, onSelectTag: (tag: Tag) -> Unit) { +fun TagList( + tags: Array, + mode: TagSelectionMode = TagSelectionMode.SINGLE, + onSelectionComplete: (tags: Array) -> Unit = {}, +) { + var selectedTags: List by remember { mutableStateOf(listOf()) } ScaffoldedScrollingColumn { - for (tag in tags) { - item { - TagChip(tag) { onSelectTag(tag) } + if (tags.isEmpty()) { + item { Text("No tags", modifier = Modifier.padding(8.dp)) } + } else { + for (tag in tags) { + item { + TagChip(tag = tag, mode = mode, onClick = { + when (mode) { + TagSelectionMode.SINGLE -> { onSelectionComplete(arrayOf(tag)) } + TagSelectionMode.MULTI -> { + if (selectedTags.contains(tag)) { + selectedTags = selectedTags.minus(tag) + } else { + selectedTags = selectedTags.plus(tag) + } + } + } + }) + } } } + item { SubmitButton(onClick = { onSelectionComplete(selectedTags.toTypedArray()) }) } } +} + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun NoTags() { + TagList(tags = arrayOf(), onSelectionComplete = {}) +} + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun WithSomeTags() { + TagList( + tags = arrayOf( + Tag(id = 123, name = "Sleep", isGeneral = false), + Tag(id = 124, name = "Personal", isGeneral = true), + ), + ) +} + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun MultiSelectMode() { + TagList( + tags = arrayOf( + Tag(id = 123, name = "Sleep", isGeneral = false), + Tag(id = 124, name = "Personal", isGeneral = true), + ), + mode = TagSelectionMode.MULTI, + ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/settings.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/settings.kt new file mode 100644 index 00000000..d4a4e047 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/settings.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.remember + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.example.util.simpletimetracker.wearrpc.Settings +import com.example.util.simpletimetracker.wearrpc.Tag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async + +/** + * Handles asynchronous retrieval of the settings for Simple Time Tracker on the connected phone. + * + * Usage: + * ``` + * val (settings, refresh) = rememberSettings() + * ``` + * + * Initially, `settings` will be `null`. Once the actual settings are received + * from the phone, `settings` will *automatically* update to the object and the encapsulating + * Composable will *automatically* re-render. + * + * `refresh` is a function you can call to forcibly re-request the settings from the phone. + */ +@Composable +fun rememberSettings(): Pair Unit> { + var rpc = rememberRPCClient() + var settings: Settings? by remember { mutableStateOf(null) } + var settingsQueryCount by remember { mutableIntStateOf(0) } + LaunchedEffect(settingsQueryCount) { + async(Dispatchers.Default) { + settings = rpc.querySettings() + } + } + return Pair(settings) { settingsQueryCount++ } +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt index 5ebf4086..233034c7 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt @@ -9,28 +9,34 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavController import com.example.util.simpletimetracker.presentation.components.TagList +import com.example.util.simpletimetracker.presentation.components.TagSelectionMode import com.example.util.simpletimetracker.presentation.mediators.CurrentActivitiesMediator import com.example.util.simpletimetracker.presentation.remember.rememberCurrentActivities import com.example.util.simpletimetracker.presentation.remember.rememberRPCClient +import com.example.util.simpletimetracker.presentation.remember.rememberSettings import com.example.util.simpletimetracker.presentation.remember.rememberTags -import com.example.util.simpletimetracker.wearrpc.CurrentActivity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.time.Instant @Composable fun TagsScreen(activityId: Long, navigation: NavController) { val coroutineScope = rememberCoroutineScope() val rpc = rememberRPCClient() + val (settings) = rememberSettings() val (tags) = rememberTags(activityId) val (currentActivities) = rememberCurrentActivities() val currentActivitiesMediator = CurrentActivitiesMediator(rpc, currentActivities) TagList( tags, - onSelectTag = { + mode = if (settings?.recordTagSelectionCloseAfterOne != false) { + TagSelectionMode.SINGLE + } else { + TagSelectionMode.MULTI + }, + onSelectionComplete = { coroutineScope.launch(Dispatchers.Default) { - currentActivitiesMediator.start(activityId, arrayOf(it)) + currentActivitiesMediator.start(activityId, it) coroutineScope.launch(Dispatchers.Main) { navigation.navigate("activities") } -- 2.51.2 From 2ff34e485c4ca9128a017807235643a48c5dfd84 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 23:29:20 -0700 Subject: [PATCH 45/56] refactor(wear): Change `color` type to Long Removes the need for transformations to and from the string hex code, improving performance and reducing the chances of a bug impacting color interpretation. closes thehale/SimpleTimeTracker-WearOS#24 --- .../util/simpletimetracker/wear/DomainAPI.kt | 8 ++++++-- .../presentation/components/ActivitiesList.kt | 4 ++-- .../presentation/components/ActivityChip.kt | 14 ++++++-------- .../simpletimetracker/presentation/theme/Color.kt | 4 ---- .../example/util/simpletimetracker/wearrpc/DTO.kt | 2 +- .../wearrpc/StartActivityMediatorTest.kt | 2 +- .../util/simpletimetracker/wearrpc/WearRPCTest.kt | 6 +++--- 7 files changed, 19 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index ed8962c1..bbe3f885 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -32,8 +32,12 @@ class DomainAPI( return recordTypeInteractor.getAll().filter { recordType -> !recordType.hidden } .map { recordType -> val color = appColorMapper.mapToColorInt(recordType.color) - val hex = String.format("#%06X", (0xFFFFFF and color)) - Activity(recordType.id, recordType.name, recordType.icon, hex) + Activity( + id = recordType.id, + name = recordType.name, + icon = recordType.icon, + color = color.toLong(), + ) }.toTypedArray() } diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt index e7fd7b2d..8d4e10c1 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -43,8 +43,8 @@ fun ActivitiesList( @Composable private fun Preview() { val activities = arrayOf( - Activity(1234, "Chores", "🧹", "#FA0000"), - Activity(4321, "Sleep", "🛏️", "#0000FA"), + Activity(1234, "Chores", "🧹", 0xFFFA0000), + Activity(4321, "Sleep", "🛏️", 0xFF0000FA), ) val currents = arrayOf( CurrentActivity(id = 4321, startedAt = 1708241427000L, tags = arrayOf()), diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index e232f726..1568bc69 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -21,7 +21,6 @@ import androidx.wear.compose.material.SwitchDefaults import androidx.wear.compose.material.Text import androidx.wear.compose.material.ToggleChipDefaults import androidx.wear.tooling.preview.devices.WearDevices -import com.example.util.simpletimetracker.presentation.theme.hexCodeToColor import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.Tag import java.time.Instant @@ -53,7 +52,6 @@ fun ActivityChip( } else { "" } - val color = hexCodeToColor(activity.color) var switchChecked = startedAt != null SplitToggleChip( modifier = Modifier @@ -74,7 +72,7 @@ fun ActivityChip( } }, colors = ToggleChipDefaults.splitToggleChipColors( - backgroundColor = color, + backgroundColor = Color(activity.color), splitBackgroundOverlayColor = if (switchChecked) { Color.White.copy(alpha = .1F) } else { @@ -125,13 +123,13 @@ fun recentTimestampToString(epochMillis: Long): String { @Preview(device = WearDevices.LARGE_ROUND) @Composable fun SampleCooking() { - ActivityChip(Activity(123, "Cooking", "🎉", "#123456")) + ActivityChip(Activity(123, "Cooking", "🎉", 0xFF123456)) } @Preview(device = WearDevices.LARGE_ROUND) @Composable fun SampleSleep() { - ActivityChip(Activity(456, "Sleeping", "🛏️", "#ABCDEF")) + ActivityChip(Activity(456, "Sleeping", "🛏️", 0xFFABCDEF)) } @Preview(device = WearDevices.LARGE_ROUND) @@ -140,20 +138,20 @@ fun White() { // TODO handle the look of light colored chips // Note: A white color is only possible when using the RGB color picker. // The default color options in the phone app are mostly darker shades. - ActivityChip(Activity(456, "Sleeping", "🛏️", "#FFFFFF")) + ActivityChip(Activity(456, "Sleeping", "🛏️", 0xFFFFFFFF)) } @Preview(device = WearDevices.LARGE_ROUND) @Composable fun CurrentlyRunning() { - ActivityChip(Activity(456, "Sleeping", "🛏️", "#ABCDEF"), startedAt = 1706751601000L) + ActivityChip(Activity(456, "Sleeping", "🛏️", 0xFFABCDEF), startedAt = 1706751601000L) } @Preview(device = WearDevices.LARGE_ROUND) @Composable fun CurrentlyRunningWithTags() { ActivityChip( - Activity(456, "Sleeping", "🛏️", "#ABCDEF"), startedAt = 1706751601000L, + Activity(456, "Sleeping", "🛏️", 0xFFABCDEF), startedAt = 1706751601000L, tags = arrayOf( Tag(id = 2, name = "Work", isGeneral = false), Tag(id = 4, name = "Hotel", isGeneral = false), diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt index 893b468e..04707998 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/theme/Color.kt @@ -24,7 +24,3 @@ internal val wearColorPalette: Colors = onSecondary = Color.Black, onError = Color.Black, ) - -fun hexCodeToColor(hex: String): Color { - return Color(android.graphics.Color.parseColor(hex)) -} \ No newline at end of file diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt index f3eb3373..34bbf9f1 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt @@ -11,7 +11,7 @@ package com.example.util.simpletimetracker.wearrpc * Object definitions for records sent between Wear/Mobile */ -data class Activity(val id: Long, val name: String, val icon: String, val color: String) +data class Activity(val id: Long, val name: String, val icon: String, val color: Long) data class CurrentActivity(val id: Long, val startedAt: Long, val tags: Array) { override fun equals(other: Any?): Boolean { diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediatorTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediatorTest.kt index bc8d7360..cea2238e 100644 --- a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediatorTest.kt +++ b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/StartActivityMediatorTest.kt @@ -22,7 +22,7 @@ open class StartActivityMediatorTestBase { onRequestTagSelection = requestTagCallback, ) - protected val sampleActivity = Activity(id = 1, name = "Sleep", icon = "🛏️", color = "#123456") + protected val sampleActivity = Activity(id = 1, name = "Sleep", icon = "🛏️", color = 0xFF123456) protected val sampleGeneralTag = Tag(id = 13, name = "Sleep", isGeneral = true) protected val sampleNonGeneralTag = Tag(id = 14, name = "Work", isGeneral = false) protected val settings = Settings( diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt index ec8e382b..0630f17e 100644 --- a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt +++ b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt @@ -68,7 +68,7 @@ class GetActivitiesTest : WearRPCServerTestBase() { @Test fun returns_one_activity_when_one_exists() = runTest { - val activities = arrayOf(Activity(42, "Chores", "🎉", "#00FF00")) + val activities = arrayOf(Activity(42, "Chores", "🎉", 0xFF00FF00)) api.mock_queryActivities(activities) val response = client.queryActivities() assertArrayEquals(activities, response) @@ -77,8 +77,8 @@ class GetActivitiesTest : WearRPCServerTestBase() { @Test fun returns_all_existing_activities() = runTest { val activities = arrayOf( - Activity(13, "Singing", "🎶", "#123456"), - Activity(24, "Homework", "📝", "#ABCDEF"), + Activity(13, "Singing", "🎶", 0xFF123456), + Activity(24, "Homework", "📝", 0xFFABCDEF), ) api.mock_queryActivities(activities) val response = client.queryActivities() -- 2.51.2 From a0f8ab5856eb16bcda1400ecf816d2660cea4d75 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Fri, 23 Feb 2024 23:47:10 -0700 Subject: [PATCH 46/56] fix: Block back navigation after TagsList It was previously possible to navigate back to a closed tag list by swiping back. This was a poor UX for two reasons: 1. Swiping back doesn't indicate the Activity for which the user is now picking tags. 2. To close the app via the back action, the user has to swipe back through **everything**. Now, swiping back on the ActivitiesScreen will always close the app. closes thehale/SimpleTimeTracker-WearOS#14 --- .../presentation/screens/TagsScreen.kt | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt index 233034c7..ca749e43 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt @@ -8,6 +8,7 @@ package com.example.util.simpletimetracker.presentation.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.navigation.NavController +import androidx.navigation.NavHostController import com.example.util.simpletimetracker.presentation.components.TagList import com.example.util.simpletimetracker.presentation.components.TagSelectionMode import com.example.util.simpletimetracker.presentation.mediators.CurrentActivitiesMediator @@ -18,6 +19,12 @@ import com.example.util.simpletimetracker.presentation.remember.rememberTags import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +fun NavHostController.navigateAndReplaceStartRoute(newHomeRoute: String) { + popBackStack(graph.startDestinationId, true) + graph.setStartDestination(newHomeRoute) + navigate(newHomeRoute) +} + @Composable fun TagsScreen(activityId: Long, navigation: NavController) { val coroutineScope = rememberCoroutineScope() @@ -38,7 +45,11 @@ fun TagsScreen(activityId: Long, navigation: NavController) { coroutineScope.launch(Dispatchers.Default) { currentActivitiesMediator.start(activityId, it) coroutineScope.launch(Dispatchers.Main) { - navigation.navigate("activities") + // Inspired by: https://stackoverflow.com/a/72856761/14765128 + navigation.navigate("activities") { + popUpTo(navigation.graph.startDestinationId) { inclusive = true } + navigation.graph.setStartDestination("activities") + } } } }, -- 2.51.2 From 0a7b1458409193d78a4beb6bf711f2637d5a39b2 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 24 Feb 2024 00:05:01 -0700 Subject: [PATCH 47/56] refactor: Lift navigation back up to controller Effectively reverts e9f977a5ac809073a71acc2226cc8bb219d7e8db The Jetpack Navigation docs [1] specifically recommend NOT passing the navController object into any other Composable because it makes them much more difficult to test. I also took the liberty to rename `MainNavigator` to `StartActivityNavigator`. We'll eventually create tiles and complications which might have different navigation flows. Choosing a more precise name for the navigator subtly informs future devs that it's okay to create a new navigator for a new experience. [1]: https://developer.android.com/jetpack/compose/navigation#testing --- .../presentation/MainActivity.kt | 4 +- .../presentation/navigation/MainNavigator.kt | 32 ------------- .../navigation/StartActivityNavigator.kt | 45 +++++++++++++++++++ .../presentation/screens/ActivitiesScreen.kt | 4 +- .../presentation/screens/TagsScreen.kt | 18 +------- 5 files changed, 51 insertions(+), 52 deletions(-) delete mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/StartActivityNavigator.kt diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt index 4cf34277..b86533a3 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/MainActivity.kt @@ -9,7 +9,7 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.runtime.Composable -import com.example.util.simpletimetracker.presentation.navigation.MainNavigator +import com.example.util.simpletimetracker.presentation.navigation.StartActivityNavigator import com.example.util.simpletimetracker.presentation.theme.SimpleTimeTrackerForWearOSTheme class MainActivity : ComponentActivity() { @@ -24,6 +24,6 @@ class MainActivity : ComponentActivity() { @Composable fun WearApp() { SimpleTimeTrackerForWearOSTheme { - MainNavigator() + StartActivityNavigator() } } diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt deleted file mode 100644 index be5ccc21..00000000 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/MainNavigator.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - */ -package com.example.util.simpletimetracker.presentation.navigation - -import androidx.compose.runtime.Composable -import androidx.wear.compose.navigation.SwipeDismissableNavHost -import androidx.wear.compose.navigation.composable -import androidx.wear.compose.navigation.rememberSwipeDismissableNavController -import com.example.util.simpletimetracker.presentation.screens.ActivitiesScreen -import com.example.util.simpletimetracker.presentation.screens.TagsScreen - -@Composable -fun MainNavigator() { - val navController = rememberSwipeDismissableNavController() - SwipeDismissableNavHost( - navController = navController, - startDestination = "activities", - ) { - composable("activities") { - ActivitiesScreen(navController) - } - composable("activities/{id}/tags") { - TagsScreen( - activityId = it.arguments?.getString("id")?.toLong()!!, - navigation = navController, - ) - } - } -} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/StartActivityNavigator.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/StartActivityNavigator.kt new file mode 100644 index 00000000..412f8961 --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/navigation/StartActivityNavigator.kt @@ -0,0 +1,45 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.navigation + +import androidx.compose.runtime.Composable +import androidx.wear.compose.navigation.SwipeDismissableNavHost +import androidx.wear.compose.navigation.composable +import androidx.wear.compose.navigation.rememberSwipeDismissableNavController +import com.example.util.simpletimetracker.presentation.screens.ActivitiesScreen +import com.example.util.simpletimetracker.presentation.screens.TagsScreen + +object Route { + const val Activities = "activities" + const val Tags = "activities/{id}/tags" +} + +@Composable +fun StartActivityNavigator() { + val navigation = rememberSwipeDismissableNavController() + SwipeDismissableNavHost( + navController = navigation, + startDestination = Route.Activities, + ) { + composable(Route.Activities) { + ActivitiesScreen(onRequestTagSelection = { + navigation.navigate(Route.Tags.replace("{id}", it.toString())) + }) + } + composable(Route.Tags) { + TagsScreen( + activityId = it.arguments?.getString("id")?.toLong()!!, + onComplete = { + // Inspired by: https://stackoverflow.com/a/72856761/14765128 + navigation.navigate(Route.Activities) { + popUpTo(navigation.graph.startDestinationId) { inclusive = true } + navigation.graph.setStartDestination(Route.Activities) + } + }, + ) + } + } +} diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt index ccfbc69d..363685c7 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Composable -fun ActivitiesScreen(navigation: NavController) { +fun ActivitiesScreen(onRequestTagSelection: (activityId: Long) -> Unit) { val coroutineScope = rememberCoroutineScope() val rpc = rememberRPCClient() val (activities, refreshActivities) = rememberActivities() @@ -40,7 +40,7 @@ fun ActivitiesScreen(navigation: NavController) { val startActivityWithTags: (Activity) -> Unit = { coroutineScope.launch(Dispatchers.Main) { Log.d("ActivitiesScreen", "Starting ${it.name} (#${it.id}) with tags") - navigation.navigate("activities/${it.id}/tags") + onRequestTagSelection(it.id) } } val stopActivity: (Activity) -> Unit = { diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt index ca749e43..59a9ead8 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/TagsScreen.kt @@ -7,8 +7,6 @@ package com.example.util.simpletimetracker.presentation.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope -import androidx.navigation.NavController -import androidx.navigation.NavHostController import com.example.util.simpletimetracker.presentation.components.TagList import com.example.util.simpletimetracker.presentation.components.TagSelectionMode import com.example.util.simpletimetracker.presentation.mediators.CurrentActivitiesMediator @@ -19,14 +17,8 @@ import com.example.util.simpletimetracker.presentation.remember.rememberTags import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -fun NavHostController.navigateAndReplaceStartRoute(newHomeRoute: String) { - popBackStack(graph.startDestinationId, true) - graph.setStartDestination(newHomeRoute) - navigate(newHomeRoute) -} - @Composable -fun TagsScreen(activityId: Long, navigation: NavController) { +fun TagsScreen(activityId: Long, onComplete: () -> Unit) { val coroutineScope = rememberCoroutineScope() val rpc = rememberRPCClient() val (settings) = rememberSettings() @@ -44,13 +36,7 @@ fun TagsScreen(activityId: Long, navigation: NavController) { onSelectionComplete = { coroutineScope.launch(Dispatchers.Default) { currentActivitiesMediator.start(activityId, it) - coroutineScope.launch(Dispatchers.Main) { - // Inspired by: https://stackoverflow.com/a/72856761/14765128 - navigation.navigate("activities") { - popUpTo(navigation.graph.startDestinationId) { inclusive = true } - navigation.graph.setStartDestination("activities") - } - } + coroutineScope.launch(Dispatchers.Main) { onComplete() } } }, ) -- 2.51.2 From a5b0a71b024ba37dbad75daffbdf0a266624d640 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 24 Feb 2024 00:30:40 -0700 Subject: [PATCH 48/56] refactor(wear): Clean up resources - Delete unused icons - Extract hard-coded strings - Add Spanish Translations for WearOS --- .../presentation/components/ActivitiesList.kt | 51 +++++++++++++++---- .../presentation/components/RefreshButton.kt | 11 +++- .../presentation/components/SubmitButton.kt | 10 +++- .../presentation/components/TagList.kt | 35 +++++++++---- .../src/main/res/drawable/baseline_bed_24.xml | 5 -- .../res/drawable/baseline_check_box_24.xml | 5 -- .../main/res/drawable/baseline_church_24.xml | 5 -- .../res/drawable/baseline_computer_24.xml | 5 -- .../drawable/baseline_directions_car_24.xml | 5 -- .../res/drawable/baseline_heart_broken_24.xml | 5 -- .../res/drawable/baseline_home_work_24.xml | 6 --- .../drawable/baseline_horizontal_rule_24.xml | 6 --- .../main/res/drawable/baseline_people_24.xml | 5 -- .../drawable/baseline_question_mark_24.xml | 5 -- .../main/res/drawable/baseline_timer_24.xml | 6 --- .../res/drawable/ic_android_black_24dp.xml | 5 -- wear/src/main/res/values-es/strings.xml | 7 +++ wear/src/main/res/values-round/strings.xml | 3 -- wear/src/main/res/values/strings.xml | 7 ++- 19 files changed, 93 insertions(+), 94 deletions(-) delete mode 100644 wear/src/main/res/drawable/baseline_bed_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_check_box_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_church_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_computer_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_directions_car_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_heart_broken_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_home_work_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_horizontal_rule_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_people_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_question_mark_24.xml delete mode 100644 wear/src/main/res/drawable/baseline_timer_24.xml delete mode 100644 wear/src/main/res/drawable/ic_android_black_24dp.xml create mode 100644 wear/src/main/res/values-es/strings.xml delete mode 100644 wear/src/main/res/values-round/strings.xml diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt index 8d4e10c1..c297c3bf 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -5,9 +5,15 @@ */ package com.example.util.simpletimetracker.presentation.components +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.wear.compose.material.Text import androidx.wear.tooling.preview.devices.WearDevices +import com.example.util.simpletimetracker.R import com.example.util.simpletimetracker.presentation.layout.ScaffoldedScrollingColumn import com.example.util.simpletimetracker.wearrpc.Activity import com.example.util.simpletimetracker.wearrpc.CurrentActivity @@ -22,23 +28,46 @@ fun ActivitiesList( onRefresh: () -> Unit, ) { ScaffoldedScrollingColumn { - for (activity in activities) { - val currentActivity = currentActivities.filter { it.id == activity.id }.getOrNull(0) - item(key = activity.id) { - ActivityChip( - activity, - startedAt = currentActivity?.startedAt, - tags = currentActivity?.tags ?: arrayOf(), - onPress = { onSelectActivity(activity) }, - onToggleOn = { onEnableActivity(activity) }, - onToggleOff = { onDisableActivity(activity) }, + if (activities.isEmpty()) { + item { + Text( + LocalContext.current.getString(R.string.no_activities), + modifier = Modifier.padding(8.dp), ) } + } else { + for (activity in activities) { + val currentActivity = currentActivities.filter { it.id == activity.id }.getOrNull(0) + item(key = activity.id) { + ActivityChip( + activity, + startedAt = currentActivity?.startedAt, + tags = currentActivity?.tags ?: arrayOf(), + onPress = { onSelectActivity(activity) }, + onToggleOn = { onEnableActivity(activity) }, + onToggleOff = { onDisableActivity(activity) }, + ) + } + } } + item { RefreshButton(onClick = onRefresh) } } } +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun NoActivities() { + ActivitiesList( + activities = arrayOf(), + currentActivities = arrayOf(), + onSelectActivity = { /* `it` is the selected activity */ }, + onEnableActivity = { /* `it` is the enabled activity */ }, + onDisableActivity = { /* `it` is the disabled activity */ }, + onRefresh = { /* What to do when requesting a refresh */ }, + ) +} + @Preview(device = WearDevices.LARGE_ROUND) @Composable private fun Preview() { @@ -50,7 +79,7 @@ private fun Preview() { CurrentActivity(id = 4321, startedAt = 1708241427000L, tags = arrayOf()), ) ActivitiesList( - activities, + activities = activities, currentActivities = currents, onSelectActivity = { /* `it` is the selected activity */ }, onEnableActivity = { /* `it` is the enabled activity */ }, diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt index b034fcb9..6427d118 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt @@ -16,10 +16,12 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.wear.compose.material.Icon import androidx.wear.compose.material.OutlinedButton +import com.example.util.simpletimetracker.R @Composable -fun RefreshButton(onClick: () -> Unit, contentDescription: String = "Refresh") { +fun RefreshButton(onClick: () -> Unit, contentDescription: String? = null) { val context = LocalContext.current + OutlinedButton( onClick = { @@ -28,7 +30,12 @@ fun RefreshButton(onClick: () -> Unit, contentDescription: String = "Refresh") { onClick() }, content = { - Icon(Icons.Rounded.Refresh, contentDescription = contentDescription) + Icon( + Icons.Rounded.Refresh, + contentDescription = contentDescription ?: context.getString( + R.string.refresh_button_default_content_description, + ), + ) }, modifier = Modifier.padding(all = 8.dp), ) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/SubmitButton.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/SubmitButton.kt index 36b5864f..4b2a1c6b 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/SubmitButton.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/SubmitButton.kt @@ -10,17 +10,23 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.wear.compose.material.Icon import androidx.wear.compose.material.OutlinedButton +import com.example.util.simpletimetracker.R @Composable -fun SubmitButton(onClick: () -> Unit, contentDescription: String = "Submit") { +fun SubmitButton(onClick: () -> Unit, contentDescription: String? = null) { OutlinedButton( onClick = onClick, content = { - Icon(Icons.AutoMirrored.Rounded.Send, contentDescription = contentDescription) + Icon( + Icons.AutoMirrored.Rounded.Send, + contentDescription = contentDescription + ?: LocalContext.current.getString(R.string.submit_button_default_content_description), + ) }, modifier = Modifier.padding(all = 8.dp), ) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt index e3b358c3..6ad33b90 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt @@ -12,10 +12,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.wear.compose.material.Text import androidx.wear.tooling.preview.devices.WearDevices +import com.example.util.simpletimetracker.R import com.example.util.simpletimetracker.presentation.layout.ScaffoldedScrollingColumn import com.example.util.simpletimetracker.wearrpc.Tag @@ -28,22 +30,33 @@ fun TagList( var selectedTags: List by remember { mutableStateOf(listOf()) } ScaffoldedScrollingColumn { if (tags.isEmpty()) { - item { Text("No tags", modifier = Modifier.padding(8.dp)) } + item { + Text( + LocalContext.current.getString(R.string.no_tags), + modifier = Modifier.padding(8.dp), + ) + } } else { for (tag in tags) { item { - TagChip(tag = tag, mode = mode, onClick = { - when (mode) { - TagSelectionMode.SINGLE -> { onSelectionComplete(arrayOf(tag)) } - TagSelectionMode.MULTI -> { - if (selectedTags.contains(tag)) { - selectedTags = selectedTags.minus(tag) - } else { - selectedTags = selectedTags.plus(tag) + TagChip( + tag = tag, mode = mode, + onClick = { + when (mode) { + TagSelectionMode.SINGLE -> { + onSelectionComplete(arrayOf(tag)) + } + + TagSelectionMode.MULTI -> { + if (selectedTags.contains(tag)) { + selectedTags = selectedTags.minus(tag) + } else { + selectedTags = selectedTags.plus(tag) + } } } - } - }) + }, + ) } } } diff --git a/wear/src/main/res/drawable/baseline_bed_24.xml b/wear/src/main/res/drawable/baseline_bed_24.xml deleted file mode 100644 index 899b686c..00000000 --- a/wear/src/main/res/drawable/baseline_bed_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/baseline_check_box_24.xml b/wear/src/main/res/drawable/baseline_check_box_24.xml deleted file mode 100644 index 2a4d0943..00000000 --- a/wear/src/main/res/drawable/baseline_check_box_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/baseline_church_24.xml b/wear/src/main/res/drawable/baseline_church_24.xml deleted file mode 100644 index 4e08ef4c..00000000 --- a/wear/src/main/res/drawable/baseline_church_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/baseline_computer_24.xml b/wear/src/main/res/drawable/baseline_computer_24.xml deleted file mode 100644 index e03e8606..00000000 --- a/wear/src/main/res/drawable/baseline_computer_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/baseline_directions_car_24.xml b/wear/src/main/res/drawable/baseline_directions_car_24.xml deleted file mode 100644 index a3e0f855..00000000 --- a/wear/src/main/res/drawable/baseline_directions_car_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/baseline_heart_broken_24.xml b/wear/src/main/res/drawable/baseline_heart_broken_24.xml deleted file mode 100644 index 6e78d0e5..00000000 --- a/wear/src/main/res/drawable/baseline_heart_broken_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/baseline_home_work_24.xml b/wear/src/main/res/drawable/baseline_home_work_24.xml deleted file mode 100644 index 9607b861..00000000 --- a/wear/src/main/res/drawable/baseline_home_work_24.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/wear/src/main/res/drawable/baseline_horizontal_rule_24.xml b/wear/src/main/res/drawable/baseline_horizontal_rule_24.xml deleted file mode 100644 index 49851abd..00000000 --- a/wear/src/main/res/drawable/baseline_horizontal_rule_24.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/baseline_people_24.xml b/wear/src/main/res/drawable/baseline_people_24.xml deleted file mode 100644 index a3ccd711..00000000 --- a/wear/src/main/res/drawable/baseline_people_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/baseline_question_mark_24.xml b/wear/src/main/res/drawable/baseline_question_mark_24.xml deleted file mode 100644 index 1d4bf082..00000000 --- a/wear/src/main/res/drawable/baseline_question_mark_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/wear/src/main/res/drawable/baseline_timer_24.xml b/wear/src/main/res/drawable/baseline_timer_24.xml deleted file mode 100644 index 7732df44..00000000 --- a/wear/src/main/res/drawable/baseline_timer_24.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - diff --git a/wear/src/main/res/drawable/ic_android_black_24dp.xml b/wear/src/main/res/drawable/ic_android_black_24dp.xml deleted file mode 100644 index fe512307..00000000 --- a/wear/src/main/res/drawable/ic_android_black_24dp.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/wear/src/main/res/values-es/strings.xml b/wear/src/main/res/values-es/strings.xml new file mode 100644 index 00000000..454919a6 --- /dev/null +++ b/wear/src/main/res/values-es/strings.xml @@ -0,0 +1,7 @@ + + + Enviar + Actualizar + Sin etiquetas + Sin actividates + \ No newline at end of file diff --git a/wear/src/main/res/values-round/strings.xml b/wear/src/main/res/values-round/strings.xml deleted file mode 100644 index 42f12297..00000000 --- a/wear/src/main/res/values-round/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - From the Round world,\nHello, %1$s! - \ No newline at end of file diff --git a/wear/src/main/res/values/strings.xml b/wear/src/main/res/values/strings.xml index 0f0b2ec6..544fa249 100644 --- a/wear/src/main/res/values/strings.xml +++ b/wear/src/main/res/values/strings.xml @@ -1,8 +1,11 @@ - Simple Time Tracker + Simple Time Tracker - From the Square world,\nHello, %1$s! + Submit + Refresh + No tags + No activities \ No newline at end of file -- 2.51.2 From 1d4014ee399b1792f4888262c3d60c3c3b31b0b6 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 24 Feb 2024 01:21:46 -0700 Subject: [PATCH 49/56] refactor(RefreshButton): Rotate instead of Toast to show progress A Toast requires text which should be localized. An animation is a better way to indicate progress without needing a verbal message. --- .../presentation/components/RefreshButton.kt | 18 ++++++++++------ .../remember/animationRotation.kt | 21 +++++++++++++++++++ .../presentation/screens/ActivitiesScreen.kt | 4 ++-- 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/animationRotation.kt diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt index 6427d118..12f6a0f8 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/RefreshButton.kt @@ -5,28 +5,32 @@ */ package com.example.util.simpletimetracker.presentation.components -import android.widget.Toast import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.wear.compose.material.Icon import androidx.wear.compose.material.OutlinedButton import com.example.util.simpletimetracker.R +import com.example.util.simpletimetracker.presentation.remember.rememberAnimationRotation @Composable fun RefreshButton(onClick: () -> Unit, contentDescription: String? = null) { val context = LocalContext.current - - + var pressCount by remember { mutableIntStateOf(0) } + val targetRotation = rememberAnimationRotation(key = pressCount) OutlinedButton( onClick = { - // Idea: Instead of a toast, spin the button while the callback is executing... - Toast.makeText(context, "Refreshing", Toast.LENGTH_SHORT).show() + pressCount++ onClick() }, content = { @@ -37,7 +41,9 @@ fun RefreshButton(onClick: () -> Unit, contentDescription: String? = null) { ), ) }, - modifier = Modifier.padding(all = 8.dp), + modifier = Modifier + .rotate(targetRotation) + .padding(all = 8.dp), ) } diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/animationRotation.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/animationRotation.kt new file mode 100644 index 00000000..d04a2f0e --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/remember/animationRotation.kt @@ -0,0 +1,21 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package com.example.util.simpletimetracker.presentation.remember + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue + +@Composable +fun rememberAnimationRotation(key: Int): Float { + val targetRotation by animateFloatAsState( + targetValue = key * 360F, + animationSpec = tween(1500), + label = "rotation", + ) + return targetRotation +} \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt index 363685c7..8ad702ea 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/screens/ActivitiesScreen.kt @@ -59,8 +59,8 @@ fun ActivitiesScreen(onRequestTagSelection: (activityId: Long) -> Unit) { ActivitiesList( - activities, - currentActivities, + activities = activities, + currentActivities = currentActivities, onSelectActivity = { coroutineScope.launch(Dispatchers.Default) { startActivitiesMediator.requestStart(it) -- 2.51.2 From 93efb41437cb668e1f891647668170a4e5bfac83 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 24 Feb 2024 01:28:02 -0700 Subject: [PATCH 50/56] feat(wear): Use Simple Time Tracker app icon Replace the stock Android icon with the same one used on the phone. closes thehale/SimpleTimeTracker-WearOS#20 --- .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 1404 -> 4534 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 982 -> 2700 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 1900 -> 6624 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 2884 -> 10548 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 3844 -> 15410 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/wear/src/main/res/mipmap-hdpi/ic_launcher.webp b/wear/src/main/res/mipmap-hdpi/ic_launcher.webp index c209e78ecd372343283f4157dcfd918ec5165bb3..38ad584f2837c27247072fdc36eeabb1349ff1db 100644 GIT binary patch literal 4534 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 sxt`5!v_jjvkEam13D%HG@k-IMc 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 literal 982 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 SV}lgOtc?*i9Gsk*;S2yKxV|X> literal 1900 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

( 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 diff --git a/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/wear/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index aa7d6427e6fa1074b79ccd52ef67ac15c5637e85..18f05feb64706dc81f39cdae0838be1822a36fde 100644 GIT binary patch literal 15410 zcmWIYbaT_PVPFV%bqWXzu<((yVPMeTe?VWr*P`IIS&u;UiAxK=|7TZ!`;Jra@BD@z zFHY+V7N*}n(3(3x?7+co<~+;z=e4*0)H=3jg1p`fHqDgJH_EyXHZQq)hlwltdFpLJ zw$Q>)S0;uOPDoLEebKgH$6wD&%flyiP2!8oUJ<6SBeH7s5+4cD_27!CQheZ9sk z^d!D2t^0@DGkc@Qd)}Y8ll#6l!l6&abK;ZJCBIHzS92Qu8?9 z?hlnQ{qoAz$w%enL=ln0Hw*vW{^WLrn>kG8^qe0H{|h$+zKW6Y%DlK}%D;Yn8K2lo zm!)4!_~{Vud||l)Pe#Z8#M;9;A7*CC9b%}LZIaC3VA#<*;ieLgNoB*C4Gg@DYce^S z1%lVO`HCD6i(a5GqxHi>V+Mt&Vs=BOnu94!HVi-Hq%Jt+T-3POu!D7hk6c4wgQG(> zgMb}#w!=b3iA5i{RIgfdhl)I4Y_RSU%HuL{(Ap}O&{#K{LAaqUVbg>82Y*N`xpebV zM#KW21J6^KT$lrR6{dQb7Bfp}#l$jjFfN_owe(s|yMs%*uqmsAl#?36{g1M*4@mtA z*Katt=%CJKgA*#VBpW_0;^pHCU}!nRa4d{rM#kp^BSwd)5VLJJlDdmFCVt47=gY;Q zwC%>c-=}Mm=5&ke>$$u?*xW77w`59EfQ-N`Hz5_vKZ)hv@7%R-?3lq&{{MP%<*!Za zm�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 FCIG@#G?f4V literal 3844 zcmWIYbaVT|$G{No>J$(bU=hK^z`!8Dz`(E{j2wLeJldHU7(>b%bUDJ3u)$aAX7!}(6$UFx zSAJ(;QR8K7Q)4t%i+=x8TwYyK?P*%phS&RMFPd4^=k>ODyUiUb+w~U`j$i(`{EcNx zX0h4Z?B#Y{6Nv6&-#EViZ?%=zb_mtm-8m%igMtLg>q%5EcXXQvfLM45im!z zY(`Of&gO=bPjM`(1aJ!@ERx-wv2=J6*7Qs!s4Ev)(YIAf+&bNI{FtF51YU75I5 z(fL)Qt&a{LtBt``nY-P&6-8H`nTfee$JbPQb-m&X%F(l5Riw=$-{<|jZ%wbHL+;lb z*6bJ7YS=tzGc11H^&)OU!{_jKC)GAdT;V&;d|qdfM;ZHx%Nw3;+4?*DLlTFex$W*f zzf`5BHmc;iEi2w{QPL{P9Je#;%&H&3sVYSc-e);%O8(A?74wskzEb=pe}C2Qi{;nX zA3ZOcc_OGNT)O=_zx}U=Cpf;z*!(pqe*Ej<{w)*J&bYpt#LxfZ=i>FkRdI!f&Isnz z8(!);arN#~p_~~L-&pT_<#MS==!vJ$422cXIf4Z|*pxSkK6y7OpiAX!&xwep)3TT3 zZzTU><}?G|1tc; zUa6ju_Wer#t9@_(mDV5rs{7CR3iDUzKkuK0XPJAOwzZ~e{s8}&E7CjUQJ&HY<SKKvfO^VCzn)BnFqJ08klIC19l-HvVN zpM?F^sku~cr)z1v!81)nS()_(pW}`*=9!a1QorARTqkZA-X8j9YwJq6LvLeM*B2l8 zykVxX&>EE%F^?B-ULQ6Uu83EZe=g@{;{8R<(Z%$afR1d{!g5b|H(~LryO|DtHt$i*Jp!Yu7CejS27u&h}|Nnnt^m()?X0BW3$>8FpLR0E4`fr*xYyYh2 zo~|;Ai+`2e&R67=uj5uU*Im24W6zVRMy$D#6B-!)eR#>hz#x48{}uUoJ*{XFroj83 zuIL3Fc`otwut8|9=6lY1v11vdv`KF~?!yPSv*m+|PD>zHx&2)!qFcWL2^cglSw@ zIluXVfpX}s!1#GOi{k!DEPe5P@g|k%$KT{XDSIAq?by9Qz_f1PJ>Q;rK^;}!+xF

0vl^(ubK57i3$j`RXVdItc2Zu zTjP%3T{qqDyHu$1wf>en`}f$lI?l)hfy+`aCTxu8`Mp4Wt8~lzsk7fIKUzdbU&D@u>@#|8JoKt2mbv8|~*&2E%ct_*EIJ>)7o&PKE5_$1H zYTdgu^~m!0A4{%YZ`c&qZ)$yr|AXm!sqXjcKR?_Pn|Ao^jNS9)1$Q|wns@3F%fsvg zwSO(SHf}Wd&gY@5dZETG=E3!B4m$1-}j)^|n6tq;mOd3k8c0DH_GHdj+bC#}?wC!#j7mQS#vf_fXiT$x+$0-Ud(~5MTubmb>WqqsT;k{xpsIQhc5wmzv^leMXZ>rUpkvod!E z%4X!{CT@DfUb}1l9;OF%&cZj$=~`?izExW$`!W#MmrU&bU9x zY4?Y|ihvD`aVe?!Jd*bn>a_Ljw%A+cxQM63#5tgD>7pvIfk zXZB`A(+B?|xK;mhgAxy6j8m5a|tYYQtz-&)`wciH>w!z4Jl6EbdLNgW&t4n&Z^`r` zg&vWF3I>a?I;mM9pQdx?h+g`4^#9q@#m5&cUv`wUW67(N(f8cqnde#^*{|z4`#8&% z$(*MTuC#KwaPVof`>D`&U$yg@H74@YWB+XNzOk9HzHI)?S*96(9jadGnl+RhV?HYU zTPymOU!2^Y9Y;SYsn1QEpStG70rM^O4*D71J+(bX{#n9KLDg&XW+cq#d%E33PUWv_ zkfOTshO5hBt#1C8*W^yL{vnXQVRqiLErQMrj6U1<_5@j|Em*A4E4-te^ZzpYDI0B` zXg;x>w9{g(u&mK%{^Fz8n{xBlOZuI;!E!86S+BwIO2Xl&Fh%#ZoBUQU_O0g4%ANk> z0Jmq=?VG;}VuQcWs7ieBJAdXY(fhi`7ADvReRQ>AUivA=PwM}&`iLJJJJZ{_&k3Y2 zUvhnu->Tx7bEg!!9%6moQp;o7lU20XJ=QDEK<4}Dx`6F-nzAlROmUdnwpX%JBgyHE&EJd_^1F8lZE|Tb`4Z9o=_5<>!KzQ} zGZoDj*B>l-_{cMi-TF`Qmi1X-=QLK8d3<{luV|^Id8CphQgh85osBxngzXY9@84_h z%f`4XHqy%Sk8jJOIRCXp`#HYZ9tjb8r7sFp_47S~Y8LMZ0|ACD*T9*?vNtsq>c#FZ;4AF83!N-}7Zh7R+l= zw!4tMaFcX!P3Els8SK9LLhRyJK z+&t%0aQM50`k&8S%~Uwp?rm21|LFNWlV+G(ACydR-+6qUWz*kv3iZ{C%If{&>euW) z@@W5$w|~FeitEW*v$L+4y5z_58%Bq(G3{FX-GAnci|2Se?q;?>GnLW&F(+=#|9Z#w z-#_o;-&VrrqOwwO%B-^WHLF&}EjZj>w|%nOHqFIdHlY~LM|@kt^cB?v?aRN!JSJaHjLeW zb$GNz$NM?U8z(oNo3`r8e%lS3CEre$zVUyZ&{wym*PqV*QMTjIf~G4kJUQ0C+9_cC zLSfq{(=7&@u0G04(^)DtZ};(To{cq(j|-+htN*jEpuxFk0?Ru8XP3^z-&SNkURt^2 zTSfZcN5x^>ML|ky1rH}oViJ7+@cetDleV!hBksSwEn~4q@Xz`GmCrjHOK)GEd0p-B z(Y6SS8poDMuE(=E|4rGmcvp9MsGHU9V8)Ek>HE*irk%F9rK7RU-2N}yiz$uUO57Qx zXRAv!tdNVQd!7^~xNg;>=ojmFGC8tun)m$oP3PKhc*onS@K5?rr^}yu zUmUrRUESJJ=>F0}m;TMP>i;SjwwR~)_nQ*mc_z*-WwSXs6&fs_bqPz9s|2|0xg0$2 t@u$Pda=rYAtrOikX7%&GRsG&;Z1Lh{&yL;Ao6ROYUN$pxVgmyL003>zdZqvX -- 2.51.2 From 75a5a44846f8c19620f4f946ecfd8217cf118608 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sat, 24 Feb 2024 01:34:10 -0700 Subject: [PATCH 51/56] fix(wear): Typo in Spanish Translation --- wear/src/main/res/values-es/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wear/src/main/res/values-es/strings.xml b/wear/src/main/res/values-es/strings.xml index 454919a6..181bd73e 100644 --- a/wear/src/main/res/values-es/strings.xml +++ b/wear/src/main/res/values-es/strings.xml @@ -3,5 +3,5 @@ Enviar Actualizar Sin etiquetas - Sin actividates + Sin actividades \ No newline at end of file -- 2.51.2 From 4e72792afa16bd0b0d2353407184ebdb6d31356c Mon Sep 17 00:00:00 2001 From: razeeman Date: Sun, 25 Feb 2024 12:18:45 +0300 Subject: [PATCH 52/56] add icon image and text support --- wear/build.gradle.kts | 2 + .../presentation/components/ActivityChip.kt | 39 ++++++++--- .../presentation/components/ActivityIcon.kt | 65 +++++++++++++++++++ wear/src/main/res/drawable/unknown.xml | 9 +++ 4 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityIcon.kt create mode 100644 wear/src/main/res/drawable/unknown.xml diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index f1ae3976..b58cc8fa 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -66,6 +66,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1") implementation("androidx.compose.material:material-icons-core:1.6.1") implementation(project(":wearrpc")) + implementation(project(":resources")) // Dev Dependencies implementation("androidx.wear:wear-tooling-preview:1.0.0") @@ -82,6 +83,7 @@ dependencies { implementation("androidx.compose.ui:ui-tooling-preview:$compose_version") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.3.1") implementation("androidx.activity:activity-compose:1.3.1") + implementation("androidx.appcompat:appcompat:1.6.0") androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version") debugImplementation("androidx.compose.ui:ui-tooling:$compose_version") debugImplementation("androidx.compose.ui:ui-test-manifest:$compose_version") diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index 1568bc69..ed47ad31 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -5,7 +5,10 @@ */ package com.example.util.simpletimetracker.presentation.components +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -37,11 +40,6 @@ fun ActivityChip( onToggleOn: () -> Unit = {}, onToggleOff: () -> Unit = {}, ) { - val briefIcon = if (activity.icon.startsWith("ic_")) { - "?" - } else { - activity.icon.substring(0, activity.icon.length.coerceAtMost(2)) - } val tagsList = if (tags.isNotEmpty()) { tags.joinToString(", ") { it.name } } else { @@ -58,11 +56,20 @@ fun ActivityChip( .fillMaxWidth(0.9f) .padding(top = 10.dp), label = { - Text( - text = "$briefIcon : ${activity.name}" + tagString, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row( + modifier = Modifier + .height(IntrinsicSize.Min), + ) { + ActivityIcon( + activityIcon = activity.icon, + ) + Text( + text = activity.name + tagString, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 2.dp) + ) + } }, secondaryLabel = { if (startedAt != null) { @@ -132,6 +139,18 @@ fun SampleSleep() { ActivityChip(Activity(456, "Sleeping", "🛏️", 0xFFABCDEF)) } +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +fun SampleText() { + ActivityChip(Activity(456, "Sleeping", "Zzzz", 0xFFABCDEF)) +} + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +fun SampleIcon() { + ActivityChip(Activity(456, "Sleeping", "ic_hotel_24px", 0xFFABCDEF)) +} + @Preview(device = WearDevices.LARGE_ROUND) @Composable fun White() { diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityIcon.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityIcon.kt new file mode 100644 index 00000000..4c93437e --- /dev/null +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityIcon.kt @@ -0,0 +1,65 @@ +package com.example.util.simpletimetracker.presentation.components + +import android.util.TypedValue +import android.view.Gravity +import androidx.appcompat.widget.AppCompatTextView +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.widget.TextViewCompat +import androidx.wear.compose.material.Icon +import androidx.wear.compose.material.LocalContentColor +import com.example.util.simpletimetracker.R + +@Composable +fun ActivityIcon( + activityIcon: String, +) { + val context = LocalContext.current + val iconIsImage = activityIcon.startsWith("ic_") + + if (iconIsImage) { + val iconDrawableRes = context.resources + .getIdentifier(activityIcon, "drawable", context.packageName) + .takeIf { it != 0 } + ?: R.drawable.unknown + Icon( + painter = painterResource(iconDrawableRes), + contentDescription = null, + modifier = Modifier + .fillMaxHeight() + .height(1.dp), + ) + } else { + val textColor = LocalContentColor.current.toArgb() + AndroidView( + factory = { context -> + val view = AppCompatTextView(context) + view.gravity = Gravity.CENTER + view.setTextColor(textColor) + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( + view, + 1, + 100, + 1, + TypedValue.COMPLEX_UNIT_SP, + ) + view + }, + modifier = Modifier + .aspectRatio(1f) + .width(0.dp), + update = { + it.text = activityIcon + }, + ) + } +} \ No newline at end of file diff --git a/wear/src/main/res/drawable/unknown.xml b/wear/src/main/res/drawable/unknown.xml new file mode 100644 index 00000000..3dbf8bbb --- /dev/null +++ b/wear/src/main/res/drawable/unknown.xml @@ -0,0 +1,9 @@ + + + -- 2.51.2 From a7c1e0386e79cf19b5efea006c60ed6c09e87ad1 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Sun, 25 Feb 2024 23:11:59 -0700 Subject: [PATCH 53/56] style: Apply IDE recommended improvements --- .../presentation/components/ActivityChip.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index ed47ad31..39264c59 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -50,24 +50,19 @@ fun ActivityChip( } else { "" } - var switchChecked = startedAt != null + val switchChecked = startedAt != null SplitToggleChip( modifier = Modifier .fillMaxWidth(0.9f) .padding(top = 10.dp), label = { - Row( - modifier = Modifier - .height(IntrinsicSize.Min), - ) { - ActivityIcon( - activityIcon = activity.icon, - ) + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + ActivityIcon(activityIcon = activity.icon) Text( text = activity.name + tagString, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(start = 2.dp) + modifier = Modifier.padding(start = 4.dp), ) } }, @@ -120,10 +115,10 @@ fun recentTimestampToString(epochMillis: Long): String { Instant.ofEpochMilli(epochMillis), ZoneId.systemDefault(), ) - if (time > LocalDateTime.ofInstant(Instant.now(), ZoneId.systemDefault()).minusDays(1)) { - return time.format(DateTimeFormatter.ISO_LOCAL_TIME) + return if (time > LocalDateTime.ofInstant(Instant.now(), ZoneId.systemDefault()).minusDays(1)) { + time.format(DateTimeFormatter.ISO_LOCAL_TIME) } else { - return time.format(DateTimeFormatter.ISO_DATE_TIME).replace("T", " ") + time.format(DateTimeFormatter.ISO_DATE_TIME).replace("T", " ") } } @@ -151,6 +146,12 @@ fun SampleIcon() { ActivityChip(Activity(456, "Sleeping", "ic_hotel_24px", 0xFFABCDEF)) } +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +fun InvalidIcon() { + ActivityChip(Activity(456, "Sleeping", "ic_gobbldeegoock_24px", 0xFFABCDEF)) +} + @Preview(device = WearDevices.LARGE_ROUND) @Composable fun White() { -- 2.51.2 From 9ec36f1a7ad9893f0399cb276b1afd647f193433 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Mon, 26 Feb 2024 23:56:41 -0700 Subject: [PATCH 54/56] dev: Add debug logs for sent messages I needed to see both sides of the communication for debugging today. Committing so the logs are available going forward. --- .../com/example/util/simpletimetracker/wearrpc/Messenger.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt index 07160b2d..f474ab9c 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/Messenger.kt @@ -32,10 +32,11 @@ class ContextMessenger(private val context: Context): Messenger { // Send the message bestNode?.also { nodeId -> Log.d(TAG, "Sending message to ${bestNode?.displayName}") + Log.d(TAG, String(message)) Wearable.getMessageClient(context).sendRequest(bestNode.id, capability, message) .addOnSuccessListener { Log.d(TAG, "Response received for $capability") - Log.d(TAG, "${String(it)}") + Log.d(TAG, String(it)) def.complete(it) }.addOnCanceledListener { val message = "Request $capability to ${bestNode.displayName} was cancelled" -- 2.51.2 From a45ccadc605f106698bd6879f0f8c09fe7478d8d Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Mon, 26 Feb 2024 23:57:15 -0700 Subject: [PATCH 55/56] 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. --- .../util/simpletimetracker/wear/DomainAPI.kt | 39 +++++++-- .../presentation/components/ActivitiesList.kt | 2 +- .../presentation/components/ActivityChip.kt | 8 +- .../presentation/components/TagChip.kt | 84 ++++++++++++++++--- .../presentation/components/TagList.kt | 25 ++---- .../util/simpletimetracker/wearrpc/DTO.kt | 2 +- .../wearrpc/StartActivityMediatorTest.kt | 30 ++++--- .../simpletimetracker/wearrpc/WearRPCTest.kt | 19 +++-- 8 files changed, 146 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt index bbe3f885..710a89e8 100644 --- a/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt +++ b/app/src/main/java/com/example/util/simpletimetracker/wear/DomainAPI.kt @@ -11,6 +11,7 @@ import com.example.util.simpletimetracker.domain.interactor.RecordTypeInteractor import com.example.util.simpletimetracker.domain.interactor.RemoveRunningRecordMediator import com.example.util.simpletimetracker.domain.interactor.RunningRecordInteractor import com.example.util.simpletimetracker.domain.mapper.AppColorMapper +import com.example.util.simpletimetracker.domain.model.AppColor import com.example.util.simpletimetracker.domain.model.RecordTag import com.example.util.simpletimetracker.domain.model.RunningRecord import com.example.util.simpletimetracker.wearrpc.Activity @@ -31,12 +32,11 @@ class DomainAPI( override suspend fun queryActivities(): Array { return recordTypeInteractor.getAll().filter { recordType -> !recordType.hidden } .map { recordType -> - val color = appColorMapper.mapToColorInt(recordType.color) Activity( id = recordType.id, name = recordType.name, icon = recordType.icon, - color = color.toLong(), + color = asColor(recordType.color), ) }.toTypedArray() } @@ -72,16 +72,37 @@ class DomainAPI( } override suspend fun queryTagsForActivity(activityId: Long): Array { + val activityColor = recordTypeInteractor.get(activityId)?.color return recordTagInteractor.getByTypeOrUntyped(activityId).filter { !it.archived } - .map { asTag(it) }.toTypedArray() + .map { asTag(it, asColor(activityColor)) }.sortedBy { it.name } + .sortedBy { it.isGeneral }.toTypedArray() } - private fun asTag(recordTag: RecordTag?): Tag { - return Tag( - id = recordTag?.id ?: -1, - name = recordTag?.name ?: "", - isGeneral = recordTag?.typeId == 0L, - ) + private fun asTag(recordTag: RecordTag?, activityColor: Long = 0x00000000): Tag { + return if (recordTag != null) { + val isGeneral = recordTag.typeId == 0L + val tagColor = if (isGeneral) { + asColor(recordTag.color) + } else { + activityColor + } + Tag( + id = recordTag.id, + name = recordTag.name, + isGeneral = isGeneral, + color = tagColor, + ) + } else { + Tag(id = -1, name = "", isGeneral = true, color = 0xFF555555) + } + } + + private fun asColor(appColor: AppColor?): Long { + return if (appColor == null) { + 0x00000000 + } else { + appColorMapper.mapToColorInt(appColor).toLong() + } } override suspend fun querySettings(): Settings { diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt index c297c3bf..f0ff5f26 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivitiesList.kt @@ -43,7 +43,7 @@ fun ActivitiesList( activity, startedAt = currentActivity?.startedAt, tags = currentActivity?.tags ?: arrayOf(), - onPress = { onSelectActivity(activity) }, + onClick = { onSelectActivity(activity) }, onToggleOn = { onEnableActivity(activity) }, onToggleOff = { onDisableActivity(activity) }, ) diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt index 39264c59..451ab112 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/ActivityChip.kt @@ -36,7 +36,7 @@ fun ActivityChip( activity: Activity, startedAt: Long? = null, tags: Array = arrayOf(), - onPress: () -> Unit = {}, + onClick: () -> Unit = {}, onToggleOn: () -> Unit = {}, onToggleOff: () -> Unit = {}, ) { @@ -89,7 +89,7 @@ fun ActivityChip( } }, checked = switchChecked, - onClick = onPress, + onClick = onClick, toggleControl = { Switch( checked = switchChecked, @@ -173,8 +173,8 @@ fun CurrentlyRunningWithTags() { ActivityChip( Activity(456, "Sleeping", "🛏️", 0xFFABCDEF), startedAt = 1706751601000L, tags = arrayOf( - Tag(id = 2, name = "Work", isGeneral = false), - Tag(id = 4, name = "Hotel", isGeneral = false), + Tag(id = 2, name = "Work", isGeneral = true, color = 0xFFFFAA22), + Tag(id = 4, name = "Hotel", isGeneral = false, color = 0xFFABCDEF), ), ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt index a2b6c850..4e496b99 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagChip.kt @@ -13,13 +13,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.wear.compose.material.Checkbox +import androidx.wear.compose.material.CheckboxDefaults import androidx.wear.compose.material.Chip +import androidx.wear.compose.material.ChipDefaults +import androidx.wear.compose.material.SplitToggleChip import androidx.wear.compose.material.Text -import androidx.wear.compose.material.ToggleChip +import androidx.wear.compose.material.ToggleChipDefaults import androidx.wear.tooling.preview.devices.WearDevices import com.example.util.simpletimetracker.wearrpc.Tag @@ -28,14 +32,25 @@ enum class TagSelectionMode { } @Composable -fun TagChip(tag: Tag, onClick: () -> Unit, mode: TagSelectionMode = TagSelectionMode.SINGLE) { +fun TagChip( + tag: Tag, + onClick: () -> Unit = {}, + onToggleOn: () -> Unit = {}, + onToggleOff: () -> Unit = {}, + mode: TagSelectionMode = TagSelectionMode.SINGLE, +) { when (mode) { TagSelectionMode.SINGLE -> { SingleSelectTagChip(tag = tag, onClick = onClick) } TagSelectionMode.MULTI -> { - MultiSelectTagChip(tag = tag, onClick = onClick) + MultiSelectTagChip( + tag = tag, + onClick = onClick, + onToggleOn = onToggleOn, + onToggleOff = onToggleOff, + ) } } @@ -46,6 +61,7 @@ private fun SingleSelectTagChip(tag: Tag, onClick: () -> Unit) { Chip( onClick = onClick, label = { Text(tag.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + colors = ChipDefaults.chipColors(backgroundColor = Color(tag.color)), modifier = Modifier .fillMaxWidth(0.9f) .padding(top = 10.dp), @@ -53,26 +69,58 @@ private fun SingleSelectTagChip(tag: Tag, onClick: () -> Unit) { } @Composable -private fun MultiSelectTagChip(tag: Tag, onClick: () -> Unit) { - var checked by remember { mutableStateOf(false) } - ToggleChip( - checked = checked, +private fun MultiSelectTagChip( + tag: Tag, + onClick: () -> Unit = {}, + onToggleOn: () -> Unit = {}, + onToggleOff: () -> Unit = {}, + checked: Boolean = false, +) { + var _checked by remember { mutableStateOf(checked) } + SplitToggleChip( + checked = _checked, onCheckedChange = { - checked = !checked - onClick() + _checked = !_checked + if (_checked) { + onToggleOn() + } else { + onToggleOff() + } }, + onClick = onClick, label = { Text(tag.name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - toggleControl = { Checkbox(checked = checked) }, + toggleControl = { + Checkbox( + checked = _checked, + colors = CheckboxDefaults.colors( + checkedBoxColor = Color.White, + checkedCheckmarkColor = Color.White, + uncheckedBoxColor = Color.White, + uncheckedCheckmarkColor = Color.White, + ), + ) + }, + colors = ToggleChipDefaults.splitToggleChipColors( + backgroundColor = Color(tag.color), + splitBackgroundOverlayColor = if (_checked) { + Color.White.copy(alpha = .1F) + } else { + Color.Black.copy(alpha = .3F) + }, + ), modifier = Modifier .fillMaxWidth(0.9f) - .padding(top = 10.dp) + .padding(top = 10.dp), ) } @Preview(device = WearDevices.LARGE_ROUND) @Composable private fun Default() { - TagChip(tag = Tag(id = 123, name = "Sleep", isGeneral = false), onClick = {}) + TagChip( + tag = Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF123456), + onClick = {}, + ) } @@ -80,8 +128,18 @@ private fun Default() { @Composable private fun MultiSelectMode() { TagChip( - tag = Tag(id = 123, name = "Sleep", isGeneral = false), + tag = Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF654321), onClick = {}, mode = TagSelectionMode.MULTI, ) +} + +@Preview(device = WearDevices.LARGE_ROUND) +@Composable +private fun MultiSelectChecked() { + MultiSelectTagChip( + tag = Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF654321), + onClick = {}, + checked = true, + ) } \ No newline at end of file diff --git a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt index 6ad33b90..51d4833d 100644 --- a/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt +++ b/wear/src/main/java/com/example/util/simpletimetracker/presentation/components/TagList.kt @@ -42,20 +42,11 @@ fun TagList( TagChip( tag = tag, mode = mode, onClick = { - when (mode) { - TagSelectionMode.SINGLE -> { - onSelectionComplete(arrayOf(tag)) - } - - TagSelectionMode.MULTI -> { - if (selectedTags.contains(tag)) { - selectedTags = selectedTags.minus(tag) - } else { - selectedTags = selectedTags.plus(tag) - } - } - } + onSelectionComplete(selectedTags.minus(tag).plus(tag).toTypedArray()) + // ^No duplicate tags^ }, + onToggleOn = { selectedTags = selectedTags.plus(tag) }, + onToggleOff = { selectedTags = selectedTags.minus(tag) }, ) } } @@ -75,8 +66,8 @@ private fun NoTags() { private fun WithSomeTags() { TagList( tags = arrayOf( - Tag(id = 123, name = "Sleep", isGeneral = false), - Tag(id = 124, name = "Personal", isGeneral = true), + Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF123456), + Tag(id = 124, name = "Personal", isGeneral = true, color = 0xFF123456), ), ) } @@ -86,8 +77,8 @@ private fun WithSomeTags() { private fun MultiSelectMode() { TagList( tags = arrayOf( - Tag(id = 123, name = "Sleep", isGeneral = false), - Tag(id = 124, name = "Personal", isGeneral = true), + Tag(id = 123, name = "Sleep", isGeneral = false, color = 0xFF123456), + Tag(id = 124, name = "Personal", isGeneral = true, color = 0xFF123456), ), mode = TagSelectionMode.MULTI, ) diff --git a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt index 34bbf9f1..977b8014 100644 --- a/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt +++ b/wearrpc/src/main/java/com/example/util/simpletimetracker/wearrpc/DTO.kt @@ -35,7 +35,7 @@ data class CurrentActivity(val id: Long, val startedAt: Long, val tags: Array Unit { +class MockMediatorCallback : (Activity) -> Unit { private var calledWith: Activity? = null private var callCount: Int = 0 override fun invoke(activity: Activity) { diff --git a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt index 0630f17e..f876c6d8 100644 --- a/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt +++ b/wearrpc/src/test/java/com/example/util/simpletimetracker/wearrpc/WearRPCTest.kt @@ -21,6 +21,11 @@ open class WearRPCServerTestBase { lateinit var messenger: Messenger lateinit var client: WearRPCClient + val tagFriends = Tag(1, "Friends", isGeneral = false, 0xFFFD3251) + val tagFamily = Tag(2, "Family", isGeneral = true, 0xFFFD3251) + val tagShopping = Tag(3, "Shopping", isGeneral = false, 0xFFFF0000) + val tagWork = Tag(14, "Work", isGeneral = false, 0xFF00FF00) + @Before fun setup() { api = MockSimpleTimeTrackerAPI() @@ -101,7 +106,7 @@ class QueryCurrentActivitiesTest : WearRPCServerTestBase() { CurrentActivity( 42, jan_31_2024_afternoon, - arrayOf(Tag(1, "Friends", isGeneral = false), Tag(2, "Family", isGeneral = false)), + arrayOf(tagFriends, tagFamily), ), ) api.mock_queryCurrentActivities(activities) @@ -117,12 +122,12 @@ class QueryCurrentActivitiesTest : WearRPCServerTestBase() { CurrentActivity( 42, jan_31_2024_afternoon, - arrayOf(Tag(1, "Friends", isGeneral = false), Tag(2, "Family", isGeneral = false)), + arrayOf(tagFriends, tagFamily), ), CurrentActivity( 42, jan_31_2024_evening, - arrayOf(Tag(5, "Shopping", isGeneral = false)), + arrayOf(tagShopping), ), ) api.mock_queryCurrentActivities(activities) @@ -147,7 +152,7 @@ class QueryTagsForActivityTest : WearRPCServerTestBase() { @Test fun returns_one_tag_associated_with_activity() = runTest { - val tags = arrayOf(Tag(5, "Shopping", isGeneral = false)) + val tags = arrayOf(tagShopping) api.mock_queryTagsForActivity(mapOf(13L to tags)) val response = client.queryTagsForActivity(13) assertArrayEquals(tags, response) @@ -155,7 +160,7 @@ class QueryTagsForActivityTest : WearRPCServerTestBase() { @Test fun returns_all_tags_associated_with_activity() = runTest { - val tags = arrayOf(Tag(5, "Shopping", isGeneral = false), Tag(14, "Work", isGeneral = false)) + val tags = arrayOf(tagShopping, tagWork) api.mock_queryTagsForActivity(mapOf(13L to tags)) val response = client.queryTagsForActivity(13L) assertArrayEquals(tags, response) @@ -163,8 +168,8 @@ class QueryTagsForActivityTest : WearRPCServerTestBase() { @Test fun returns_only_tags_associated_with_requested_activity() = runTest { - val tags = arrayOf(Tag(5, "Shopping", isGeneral = false), Tag(14, "Work", isGeneral = false)) - val otherTags = arrayOf(Tag(7, "Chores", isGeneral = false), Tag(13, "Sleep", isGeneral = false)) + val tags = arrayOf(tagShopping, tagWork) + val otherTags = arrayOf(tagFriends, tagFamily) api.mock_queryTagsForActivity(mapOf(10L to tags, 17L to otherTags)) val response = client.queryTagsForActivity(10L) assertArrayEquals(tags, response) -- 2.51.2 From 071093372693a4cabf407b0c724f31fcb218130b Mon Sep 17 00:00:00 2001 From: razeeman Date: Tue, 27 Feb 2024 14:48:25 +0300 Subject: [PATCH 56/56] remove custom wear os header --- README.md | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/README.md b/README.md index 218d553f..13cdd0a6 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,3 @@ -

- -# Simple Time Tracker for Wear OS - -A fork of Simple Time Tracker with WearOS support. - - -[![](https://badgen.net/github/license/thehale/SimpleTimeTracker-WearOS)](https://github.com/thehale/SimpleTimeTracker-WearOS/blob/master/LICENSE) -[![](https://badgen.net/badge/icon/Sponsor/pink?icon=github&label)](https://github.com/sponsors/thehale) -[![](https://img.shields.io/badge/Follow-thehale-0A66C2?logo=linkedin)](https://www.linkedin.com/comm/mynetwork/discovery-see-all?usecase=PEOPLE_FOLLOWS&followMember=thehale) - -| | | -| ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| ![Application screenshot](docs/simpletimetracker-wearos-list-start.png) | ![Application screenshot](docs/simpletimetracker-wearos-list-middle.png) | - -
- -## WearOS Status - -While **not ready for production**, this project has a solid foundation for Wear -<-> -Mobile communication and is now open to contributors for adding functionality -and UI/Design. - -Check the GitHub Issues for details about the next steps. Once this fork reaches -stability, we will open a PR back into the upstream application so everyone can -benefit from these improvements! - ------- ------- - -*Original README...* - # Simple Time Tracker Simple app that helps track how much time you spend on all the useless activities in the world. -- 2.51.2