diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b78a8515d..f316a7dc5 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -237,7 +237,7 @@ dependencies {
implementation("androidx.compose.material:material")
implementation("androidx.compose.runtime:runtime-livedata")
implementation(libs.androidx.activity.compose)
- implementation("androidx.compose.material:material-icons-extended")
+ implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation("androidx.compose.ui:ui-viewbinding")
implementation("androidx.compose.ui:ui-tooling-preview")
@@ -261,6 +261,11 @@ dependencies {
googleplayImplementation(libs.play.billing.ktx)
googleplayImplementation(libs.play.review)
googleplayImplementation(libs.play.services.oss.licenses)
+ googleplayImplementation(libs.horologist.datalayer.phone)
+ googleplayImplementation(libs.horologist.datalayer.grpc)
+ googleplayImplementation(libs.horologist.datalayer.core)
+ googleplayImplementation(libs.play.services.wearable)
+ googleplayImplementation(projects.wearDatalayer)
androidTestImplementation(libs.dagger.hilt.testing)
kspAndroidTest(libs.dagger.hilt.compiler)
diff --git a/app/src/googleplay/AndroidManifest.xml b/app/src/googleplay/AndroidManifest.xml
index 9f7952f73..582361fe3 100644
--- a/app/src/googleplay/AndroidManifest.xml
+++ b/app/src/googleplay/AndroidManifest.xml
@@ -40,6 +40,19 @@
android:name=".location.GoogleGeofenceTransitionIntentService"
android:permission="android.permission.BIND_JOB_SERVICE"/>
+
+
+
+
+
+
+
diff --git a/app/src/googleplay/java/org/tasks/wear/WearDataService.kt b/app/src/googleplay/java/org/tasks/wear/WearDataService.kt
new file mode 100644
index 000000000..16238f9b0
--- /dev/null
+++ b/app/src/googleplay/java/org/tasks/wear/WearDataService.kt
@@ -0,0 +1,38 @@
+package org.tasks.wear
+
+import androidx.lifecycle.lifecycleScope
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.data.WearDataLayerRegistry
+import com.google.android.horologist.datalayer.grpc.server.BaseGrpcDataService
+import com.todoroo.astrid.dao.TaskDao
+import com.todoroo.astrid.service.TaskCompleter
+import dagger.hilt.android.AndroidEntryPoint
+import org.tasks.WearServiceGrpcKt
+import org.tasks.preferences.Preferences
+import javax.inject.Inject
+
+@OptIn(ExperimentalHorologistApi::class)
+@AndroidEntryPoint
+class WearDataService : BaseGrpcDataService() {
+
+ @Inject lateinit var taskDao: TaskDao
+ @Inject lateinit var preferences: Preferences
+ @Inject lateinit var taskCompleter: TaskCompleter
+
+ override val registry: WearDataLayerRegistry by lazy {
+ WearDataLayerRegistry.fromContext(
+ application = applicationContext,
+ coroutineScope = lifecycleScope,
+ ).apply {
+ registerSerializer(TasksSerializer)
+ }
+ }
+
+ override fun buildService(): WearServiceGrpcKt.WearServiceCoroutineImplBase {
+ return WearService(
+ taskDao = taskDao,
+ preferences = preferences,
+ taskCompleter = taskCompleter,
+ )
+ }
+}
diff --git a/app/src/googleplay/java/org/tasks/wear/WearService.kt b/app/src/googleplay/java/org/tasks/wear/WearService.kt
new file mode 100644
index 000000000..4f1d5f497
--- /dev/null
+++ b/app/src/googleplay/java/org/tasks/wear/WearService.kt
@@ -0,0 +1,49 @@
+package org.tasks.wear
+
+import com.todoroo.astrid.dao.TaskDao
+import com.todoroo.astrid.service.TaskCompleter
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.tasks.GrpcProto.CompleteTaskRequest
+import org.tasks.GrpcProto.CompleteTaskResponse
+import org.tasks.GrpcProto.GetTasksRequest
+import org.tasks.GrpcProto.Task
+import org.tasks.GrpcProto.Tasks
+import org.tasks.WearServiceGrpcKt
+import org.tasks.filters.MyTasksFilter
+import org.tasks.preferences.Preferences
+
+class WearService(
+ private val taskDao: TaskDao,
+ private val preferences: Preferences,
+ private val taskCompleter: TaskCompleter,
+) : WearServiceGrpcKt.WearServiceCoroutineImplBase() {
+
+ override suspend fun getTasks(request: GetTasksRequest): Tasks {
+ return Tasks.newBuilder()
+ .addAllTasks(getTasks())
+ .build()
+ }
+
+ override suspend fun completeTask(request: CompleteTaskRequest): CompleteTaskResponse {
+ taskCompleter.setComplete(request.id, request.completed)
+ return CompleteTaskResponse.newBuilder().setSuccess(true).build()
+ }
+
+ private suspend fun getTasks(): List = withContext(Dispatchers.IO) {
+ val tasks = taskDao.fetchTasks(preferences, MyTasksFilter.create())
+ return@withContext tasks.map {
+ Task.newBuilder()
+ .setId(it.task.id)
+ .setPriority(it.task.priority)
+ .setCompleted(it.task.isCompleted)
+ .apply {
+ if (it.task.title != null) {
+ setTitle(it.task.title)
+ }
+ }
+ .setRepeating(it.task.isRecurring)
+ .build()
+ }
+ }
+}
diff --git a/app/src/googleplay/res/values/wear.xml b/app/src/googleplay/res/values/wear.xml
new file mode 100644
index 000000000..8816152a4
--- /dev/null
+++ b/app/src/googleplay/res/values/wear.xml
@@ -0,0 +1,7 @@
+
+
+
+ - data_layer_app_helper_device_phone
+ - horologist_phone
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/todoroo/astrid/service/TaskCompleter.kt b/app/src/main/java/com/todoroo/astrid/service/TaskCompleter.kt
index fd6274653..4ba34a590 100644
--- a/app/src/main/java/com/todoroo/astrid/service/TaskCompleter.kt
+++ b/app/src/main/java/com/todoroo/astrid/service/TaskCompleter.kt
@@ -33,10 +33,10 @@ class TaskCompleter @Inject internal constructor(
private val gCalHelper: GCalHelper,
private val workManager: WorkManager,
) {
- suspend fun setComplete(taskId: Long) =
+ suspend fun setComplete(taskId: Long, completed: Boolean = true) =
taskDao
.fetch(taskId)
- ?.let { setComplete(it, true) }
+ ?.let { setComplete(it, completed) }
?: Timber.e("Could not find task $taskId")
suspend fun setComplete(item: Task, completed: Boolean, includeChildren: Boolean = true) {
diff --git a/build.gradle.kts b/build.gradle.kts
index 75731a447..5ebc81aca 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,6 +8,7 @@ plugins {
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false
+ alias(libs.plugins.protobuf) apply false
}
buildscript {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ac2405512..bfc90f397 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,4 +1,5 @@
[versions]
+agp = "8.7.0"
android-compileSdk = "34"
android-minSdk = "24"
android-targetSdk = "34"
@@ -24,8 +25,9 @@ google-oauth2 = "1.24.0"
google-api-drive = "v3-rev20240914-2.0.0"
google-api-tasks = "v1-rev20240630-2.0.0"
google-services = "4.4.2"
-agp = "8.7.0"
+grpc = "1.68.0"
hilt = "1.2.0"
+horologist = "0.6.20"
ical4android = "12fe73a"
jchronic = "0.2.6"
jems = "1.33"
@@ -50,6 +52,7 @@ play-services-maps = "19.0.0"
play-services-location = "21.3.0"
play-services-oss-licenses = "17.1.0"
preference = "1.2.1"
+protobuf = "4.28.2"
recyclerview = "1.3.2"
retrofit = "2.9.0"
rfc5545-datetime = "0.2.4"
@@ -62,6 +65,7 @@ work = "2.8.1"
androidx-test = "1.6.1"
androidx-test-runner = "1.6.2"
xpp3 = "1.1.6"
+wearCompose = "1.4.0"
[libraries]
accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayout", version.ref = "accompanist" }
@@ -72,6 +76,7 @@ androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "a
androidx-compose = { module = "androidx.compose:compose-bom", version.ref = "compose" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" }
+androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version = "1.0.1" }
androidx-datastore = { module = "androidx.datastore:datastore-preferences", version = "1.1.1" }
androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment-ktx" }
androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt" }
@@ -81,6 +86,7 @@ androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime",
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }
androidx-room = { module = "androidx.room:room-runtime", version.ref = "room" }
@@ -120,6 +126,13 @@ google-api-drive = { module = "com.google.apis:google-api-services-drive", versi
google-api-tasks = { module = "com.google.apis:google-api-services-tasks", version.ref = "google-api-tasks" }
google-services = { module = "com.google.gms:google-services", version.ref = "google-services" }
gradle = { module = "com.android.tools.build:gradle", version.ref = "agp" }
+horologist-compose-layout = { group = "com.google.android.horologist", name = "horologist-compose-layout", version.ref = "horologist" }
+horologist-compose-material = { group = "com.google.android.horologist", name = "horologist-compose-material", version.ref = "horologist" }
+horologist-compose-tools = { group = "com.google.android.horologist", name = "horologist-compose-tools", version.ref = "horologist" }
+horologist-datalayer-core = { group = "com.google.android.horologist", name = "horologist-datalayer", version.ref = "horologist" }
+horologist-datalayer-grpc = { group = "com.google.android.horologist", name = "horologist-datalayer-grpc", version.ref = "horologist" }
+horologist-datalayer-phone = { group = "com.google.android.horologist", name = "horologist-datalayer-phone", version.ref = "horologist" }
+horologist-datalayer-watch = { group = "com.google.android.horologist", name = "horologist-datalayer-watch", version.ref = "horologist" }
jchronic = { module = "com.rubiconproject.oss:jchronic", version.ref = "jchronic" }
junit = { module = "junit:junit", version.ref = "junit-junit" }
kermit = { module = "co.touchlab:kermit", version = "2.0.4" }
@@ -159,6 +172,27 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
xpp3 = { module = "org.ogce:xpp3", version.ref = "xpp3" }
androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version = "1.7.2" }
iconics = { module = "com.mikepenz:iconics-core", version = "5.5.0-b01" }
+play-services-wearable = { group = "com.google.android.gms", name = "play-services-wearable", version = "18.2.0" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
+androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
+wear-compose-foundation = { group = "androidx.wear.compose", name = "compose-foundation", version.ref = "wearCompose" }
+wear-compose-material = { group = "androidx.wear.compose", name = "compose-material", version.ref = "wearCompose" }
+wear-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "wearCompose" }
+wear-tooling-preview = { group = "androidx.wear", name = "wear-tooling-preview", version = "1.0.0" }
+
+protobuf-protoc-gen-grpc-java = { module = "io.grpc:protoc-gen-grpc-java", version.ref = "grpc" }
+protobuf-protoc-gen-grpc-kotlin = { module = "io.grpc:protoc-gen-grpc-kotlin", version = "1.3.0:jdk8@jar" }
+protobuf-protoc-gen-javalite = { group = "com.google.protobuf", name = "protoc-gen-javalite", version = "3.0.0" }
+protobuf-protoc-stnd = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
+io-grpc-grpc-android = { group = "io.grpc", name = "grpc-android", version.ref = "grpc" }
+io-grpc-grpc-binder = { group = "io.grpc", name = "grpc-binder", version.ref = "grpc" }
+io-grpc-grpc-kotlin = { group = "io.grpc", name = "grpc-kotlin-stub", version = "1.4.1" }
+io-grpc-protobuf-lite = { group = "io.grpc", name = "grpc-protobuf-lite", version.ref = "grpc" }
+protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -171,3 +205,4 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi
ksp = { id = "com.google.devtools.ksp", version = "2.0.10-1.0.24" }
jetbrains-kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+protobuf = { id = "com.google.protobuf", version = "0.9.4" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8b769bddf..4fccba7d6 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -44,3 +44,5 @@ include("app")
include("data")
include(":kmp")
include(":icons")
+include(":wear")
+include(":wear-datalayer")
diff --git a/wear-datalayer/.gitignore b/wear-datalayer/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/wear-datalayer/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/wear-datalayer/build.gradle.kts b/wear-datalayer/build.gradle.kts
new file mode 100644
index 000000000..88691891e
--- /dev/null
+++ b/wear-datalayer/build.gradle.kts
@@ -0,0 +1,88 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.jetbrains.kotlin.android)
+ id("com.google.protobuf")
+}
+
+android {
+ namespace = "org.tasks.wear"
+ compileSdk = 34
+
+ defaultConfig {
+ minSdk = 24
+
+ 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_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+val archSuffix = if (System.getProperty("os.name").contains("mac", ignoreCase = true))
+ ":osx-x86_64"
+else
+ ""
+
+protobuf {
+ protoc {
+ artifact = libs.protobuf.protoc.stnd.get().toString() + archSuffix
+ }
+ plugins {
+ create("javalite") {
+ artifact = libs.protobuf.protoc.gen.javalite.get().toString() + archSuffix
+ }
+ create("grpc") {
+ artifact = libs.protobuf.protoc.gen.grpc.java.get().toString()
+ }
+ create("grpckt") {
+ artifact = libs.protobuf.protoc.gen.grpc.kotlin.get().toString()
+ }
+ }
+ generateProtoTasks {
+ all().forEach { task ->
+ task.builtins {
+ create("java") {
+ option("lite")
+ }
+ create("kotlin") {
+ option("lite")
+ }
+ }
+ task.plugins {
+ create("grpc") {
+ option("lite")
+ }
+ create("grpckt") {
+ option("lite")
+ }
+ }
+ }
+ }
+}
+
+dependencies {
+ api(libs.io.grpc.grpc.kotlin)
+ api(libs.io.grpc.protobuf.lite)
+
+ api(libs.io.grpc.grpc.android)
+ api(libs.io.grpc.grpc.binder)
+ implementation(libs.horologist.datalayer.core)
+
+ implementation(libs.androidx.datastore)
+ implementation(libs.protobuf.kotlin.lite)
+}
\ No newline at end of file
diff --git a/wear-datalayer/consumer-rules.pro b/wear-datalayer/consumer-rules.pro
new file mode 100644
index 000000000..e69de29bb
diff --git a/wear-datalayer/proguard-rules.pro b/wear-datalayer/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/wear-datalayer/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-datalayer/src/main/AndroidManifest.xml b/wear-datalayer/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a5918e68a
--- /dev/null
+++ b/wear-datalayer/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/wear-datalayer/src/main/java/org/tasks/wear/TasksSerializer.kt b/wear-datalayer/src/main/java/org/tasks/wear/TasksSerializer.kt
new file mode 100644
index 000000000..174dbe14b
--- /dev/null
+++ b/wear-datalayer/src/main/java/org/tasks/wear/TasksSerializer.kt
@@ -0,0 +1,24 @@
+package org.tasks.wear
+
+import androidx.datastore.core.CorruptionException
+import androidx.datastore.core.Serializer
+import com.google.protobuf.InvalidProtocolBufferException
+import org.tasks.GrpcProto.Tasks
+import java.io.InputStream
+import java.io.OutputStream
+
+object TasksSerializer : Serializer {
+ override val defaultValue: Tasks
+ get() = Tasks.getDefaultInstance()
+
+ override suspend fun readFrom(input: InputStream): Tasks =
+ try {
+ Tasks.parseFrom(input)
+ } catch (exception: InvalidProtocolBufferException) {
+ throw CorruptionException("Cannot read proto.", exception)
+ }
+
+ override suspend fun writeTo(t: Tasks, output: OutputStream) {
+ t.writeTo(output)
+ }
+}
\ No newline at end of file
diff --git a/wear-datalayer/src/main/proto/grpc.proto b/wear-datalayer/src/main/proto/grpc.proto
new file mode 100644
index 000000000..51d072d27
--- /dev/null
+++ b/wear-datalayer/src/main/proto/grpc.proto
@@ -0,0 +1,30 @@
+syntax = "proto3";
+
+package org.tasks.grpc;
+
+option java_package = "org.tasks";
+option java_outer_classname = "GrpcProto";
+
+message Task {
+ uint64 id = 1;
+ string title = 2;
+ bool completed = 3;
+ uint32 priority = 4;
+ bool repeating = 5;
+}
+
+message Tasks {
+ repeated Task tasks = 1;
+}
+
+message GetTasksRequest {}
+message CompleteTaskRequest {
+ uint64 id = 1;
+ bool completed = 2;
+}
+message CompleteTaskResponse { bool success = 1; }
+
+service WearService {
+ rpc getTasks(GetTasksRequest) returns (Tasks);
+ rpc completeTask(CompleteTaskRequest) returns (CompleteTaskResponse);
+}
\ No newline at end of file
diff --git a/wear/.gitignore b/wear/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/wear/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts
new file mode 100644
index 000000000..8b38effd4
--- /dev/null
+++ b/wear/build.gradle.kts
@@ -0,0 +1,69 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.jetbrains.kotlin.android)
+ alias(libs.plugins.kotlin.compose.compiler)
+}
+
+android {
+ namespace = "org.tasks"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "org.tasks"
+ minSdk = 30
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+ compileOptions {
+ isCoreLibraryDesugaringEnabled = true
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+
+ tasks.register("testClasses")
+}
+
+dependencies {
+ coreLibraryDesugaring(libs.desugar.jdk.libs)
+ implementation(projects.wearDatalayer)
+ implementation(projects.kmp)
+ implementation(libs.play.services.wearable)
+ implementation(platform(libs.androidx.compose))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material.icons.extended)
+ implementation(libs.wear.compose.material)
+ implementation(libs.wear.compose.foundation)
+ implementation(libs.wear.compose.navigation)
+ implementation(libs.wear.tooling.preview)
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.core.splashscreen)
+ implementation(libs.horologist.compose.layout)
+ implementation(libs.horologist.compose.material)
+ implementation(libs.horologist.compose.tools)
+ implementation(libs.horologist.datalayer.watch)
+ implementation(libs.horologist.datalayer.core)
+ implementation(libs.horologist.datalayer.grpc)
+ androidTestImplementation(platform(libs.androidx.compose))
+ androidTestImplementation(libs.androidx.ui.test.junit4)
+ debugImplementation(libs.androidx.ui.tooling)
+ debugImplementation(libs.androidx.ui.test.manifest)
+}
\ No newline at end of file
diff --git a/wear/lint.xml b/wear/lint.xml
new file mode 100644
index 000000000..44fac75b8
--- /dev/null
+++ b/wear/lint.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/proguard-rules.pro b/wear/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /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 000000000..fc2d02eb3
--- /dev/null
+++ b/wear/src/main/AndroidManifest.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/wear/src/main/java/org/tasks/presentation/MainActivity.kt b/wear/src/main/java/org/tasks/presentation/MainActivity.kt
new file mode 100644
index 000000000..65b7193e6
--- /dev/null
+++ b/wear/src/main/java/org/tasks/presentation/MainActivity.kt
@@ -0,0 +1,112 @@
+/* While this template provides a good starting point for using Wear Compose, you can always
+ * take a look at https://github.com/android/wear-os-samples/tree/main/ComposeStarter to find the
+ * most up to date changes to the libraries and their usages.
+ */
+
+package org.tasks.presentation
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavType
+import androidx.navigation.navArgument
+import androidx.wear.compose.material.MaterialTheme
+import androidx.wear.compose.material.Text
+import androidx.wear.compose.material.TimeText
+import androidx.wear.compose.navigation.SwipeDismissableNavHost
+import androidx.wear.compose.navigation.composable
+import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
+import androidx.wear.tooling.preview.devices.WearDevices
+import com.google.android.horologist.compose.layout.AppScaffold
+import org.tasks.R
+import org.tasks.presentation.screens.TaskListScreen
+import org.tasks.presentation.screens.TaskListViewModel
+import org.tasks.presentation.theme.TasksTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+
+ super.onCreate(savedInstanceState)
+
+ setTheme(android.R.style.Theme_DeviceDefault)
+
+ setContent {
+ TasksTheme {
+ AppScaffold(
+ timeText = { TimeText() },
+ modifier = Modifier.background(MaterialTheme.colors.background),
+ ) {
+ val navController = rememberSwipeDismissableNavController()
+ SwipeDismissableNavHost(
+ startDestination = "task_list",
+ navController = navController,
+ ) {
+ composable("task_list") { navBackStackEntry ->
+ val viewModel: TaskListViewModel = viewModel(navBackStackEntry)
+ val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
+ TaskListScreen(
+ tasks = uiState.tasks.tasksList,
+ onComplete = { viewModel.completeTask(it) },
+ onClick = { navController.navigate("task_edit/$it") },
+ )
+ }
+ composable(
+ route = "task_edit/{taskId}",
+ arguments = listOf(
+ navArgument("taskId") { type = NavType.StringType }
+ )
+ ) {
+ val taskId = it.arguments?.getString("taskId")
+ WearApp(taskId ?: "invalid id")
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun WearApp(greetingName: String) {
+ TasksTheme {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colors.background),
+ contentAlignment = Alignment.Center
+ ) {
+ TimeText()
+ Greeting(greetingName = greetingName)
+ }
+ }
+}
+
+@Composable
+fun Greeting(greetingName: String) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colors.primary,
+ text = stringResource(R.string.hello_world, greetingName)
+ )
+}
+
+@Preview(device = WearDevices.SMALL_ROUND, showSystemUi = true)
+@Composable
+fun DefaultPreview() {
+ WearApp("Preview Android")
+}
\ No newline at end of file
diff --git a/wear/src/main/java/org/tasks/presentation/MainActivityViewModel.kt b/wear/src/main/java/org/tasks/presentation/MainActivityViewModel.kt
new file mode 100644
index 000000000..e47ce2448
--- /dev/null
+++ b/wear/src/main/java/org/tasks/presentation/MainActivityViewModel.kt
@@ -0,0 +1,4 @@
+package org.tasks.presentation
+
+class MainActivityViewModel {
+}
\ No newline at end of file
diff --git a/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt b/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt
new file mode 100644
index 000000000..6f2b8fef5
--- /dev/null
+++ b/wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt
@@ -0,0 +1,94 @@
+package org.tasks.presentation.screens
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.CheckBox
+import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank
+import androidx.compose.material.icons.outlined.Repeat
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.Button
+import androidx.wear.compose.material.ButtonDefaults
+import androidx.wear.compose.material.Card
+import androidx.wear.compose.material.Icon
+import androidx.wear.compose.material.MaterialTheme
+import androidx.wear.compose.material.Text
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.compose.layout.ScalingLazyColumn
+import com.google.android.horologist.compose.layout.ScreenScaffold
+import com.google.android.horologist.compose.layout.rememberResponsiveColumnState
+import org.tasks.GrpcProto
+import org.tasks.kmp.org.tasks.themes.ColorProvider
+
+@OptIn(ExperimentalHorologistApi::class)
+@Composable
+fun TaskListScreen(
+ tasks: MutableList,
+ onComplete: (Long) -> Unit,
+ onClick: (Long) -> Unit,
+) {
+ val columnState = rememberResponsiveColumnState()
+ ScreenScaffold(scrollState = columnState) {
+ ScalingLazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ columnState = columnState,
+ ) {
+ items(tasks.size) { index ->
+ val task = tasks[index]
+ key(task.id) {
+ TaskCard(
+ task = task,
+ onComplete = { onComplete(task.id) },
+ onClick = { onClick(task.id) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun TaskCard(
+ task: GrpcProto.Task,
+ onComplete: () -> Unit,
+ onClick: () -> Unit,
+) {
+ Card(
+ onClick = onClick,
+ backgroundPainter = ColorPainter(MaterialTheme.colors.surface),
+ contentPadding = PaddingValues(start = 0.dp, top = 0.dp, end = 12.dp, bottom = 0.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Button(
+ onClick = onComplete,
+ colors = ButtonDefaults.iconButtonColors(),
+ ) {
+ Icon(
+ imageVector = when {
+ task.completed -> Icons.Outlined.CheckBox
+ task.repeating -> Icons.Outlined.Repeat
+ else -> Icons.Outlined.CheckBoxOutlineBlank
+ },
+ tint = Color(
+ ColorProvider.priorityColor(task.priority, isDarkMode = true, desaturate = true)
+ ),
+ contentDescription = null,
+ )
+ }
+ Text(
+ text = task.title,
+ modifier = Modifier.padding(vertical = 12.dp)
+ )
+ }
+ }
+}
diff --git a/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt b/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt
new file mode 100644
index 000000000..d44c138b3
--- /dev/null
+++ b/wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt
@@ -0,0 +1,54 @@
+package org.tasks.presentation.screens
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.android.horologist.annotations.ExperimentalHorologistApi
+import com.google.android.horologist.data.TargetNodeId
+import com.google.android.horologist.data.WearDataLayerRegistry
+import com.google.android.horologist.datalayer.grpc.GrpcExtensions.grpcClient
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.tasks.GrpcProto
+import org.tasks.GrpcProto.Tasks
+import org.tasks.WearServiceGrpcKt
+import org.tasks.wear.TasksSerializer
+
+data class TaskListScreenState(
+ val error: String? = null,
+ val tasks: Tasks = Tasks.getDefaultInstance(),
+)
+
+@OptIn(ExperimentalHorologistApi::class)
+class TaskListViewModel(
+ application: Application
+) : AndroidViewModel(application) {
+ val uiState: MutableStateFlow = MutableStateFlow(TaskListScreenState())
+ private val scope = CoroutineScope(Dispatchers.IO)
+ private val wearDataLayerRegistry = WearDataLayerRegistry.fromContext(
+ application = application,
+ coroutineScope = scope,
+ ).apply {
+ registerSerializer(TasksSerializer)
+ }
+ private val wearService : WearServiceGrpcKt.WearServiceCoroutineStub = wearDataLayerRegistry.grpcClient(
+ nodeId = TargetNodeId.PairedPhone,
+ coroutineScope = scope,
+ ) {
+ WearServiceGrpcKt.WearServiceCoroutineStub(it)
+ }
+
+ init {
+ viewModelScope.launch {
+ val tasks = wearService.getTasks(GrpcProto.GetTasksRequest.getDefaultInstance())
+ uiState.update { it.copy(tasks = tasks) }
+ }
+ }
+
+ fun completeTask(it: Long) = viewModelScope.launch {
+ wearService.completeTask(GrpcProto.CompleteTaskRequest.newBuilder().setId(it).setCompleted(true).build())
+ }
+}
\ No newline at end of file
diff --git a/wear/src/main/java/org/tasks/presentation/theme/Theme.kt b/wear/src/main/java/org/tasks/presentation/theme/Theme.kt
new file mode 100644
index 000000000..0e89db4d3
--- /dev/null
+++ b/wear/src/main/java/org/tasks/presentation/theme/Theme.kt
@@ -0,0 +1,17 @@
+package org.tasks.presentation.theme
+
+import androidx.compose.runtime.Composable
+import androidx.wear.compose.material.MaterialTheme
+
+@Composable
+fun TasksTheme(
+ content: @Composable () -> Unit
+) {
+ /**
+ * Empty theme to customize for your app.
+ * See: https://developer.android.com/jetpack/compose/designsystems/custom
+ */
+ MaterialTheme(
+ content = content
+ )
+}
\ No newline at end of file
diff --git a/wear/src/main/res/mipmap-anydpi/ic_launcher.xml b/wear/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 000000000..37c0a4d4c
--- /dev/null
+++ b/wear/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ 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
new file mode 100644
index 000000000..42f12297f
--- /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 000000000..3f6cee2db
--- /dev/null
+++ b/wear/src/main/res/values/strings.xml
@@ -0,0 +1,8 @@
+
+ Tasks.org
+
+ From the Square world,\nHello, %1$s!
+
\ No newline at end of file
diff --git a/wear/src/main/res/values/styles.xml b/wear/src/main/res/values/styles.xml
new file mode 100644
index 000000000..4de954156
--- /dev/null
+++ b/wear/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
+
\ No newline at end of file
diff --git a/wear/src/main/res/values/wear.xml b/wear/src/main/res/values/wear.xml
new file mode 100644
index 000000000..79e87c8cc
--- /dev/null
+++ b/wear/src/main/res/values/wear.xml
@@ -0,0 +1,7 @@
+
+
+
+ - data_layer_app_helper_device_watch
+ - horologist_watch
+
+
\ No newline at end of file