From ff1f7e1d0141342cd0a090abc6196d431ffca16d Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Sat, 12 Oct 2024 16:53:24 -0500 Subject: [PATCH] Android Wear - WIP --- app/build.gradle.kts | 7 +- app/src/googleplay/AndroidManifest.xml | 13 ++ .../java/org/tasks/wear/WearDataService.kt | 38 ++++++ .../java/org/tasks/wear/WearService.kt | 49 ++++++++ app/src/googleplay/res/values/wear.xml | 7 ++ .../todoroo/astrid/service/TaskCompleter.kt | 4 +- build.gradle.kts | 1 + gradle/libs.versions.toml | 37 +++++- settings.gradle.kts | 2 + wear-datalayer/.gitignore | 1 + wear-datalayer/build.gradle.kts | 88 ++++++++++++++ wear-datalayer/consumer-rules.pro | 0 wear-datalayer/proguard-rules.pro | 21 ++++ wear-datalayer/src/main/AndroidManifest.xml | 4 + .../java/org/tasks/wear/TasksSerializer.kt | 24 ++++ wear-datalayer/src/main/proto/grpc.proto | 30 +++++ wear/.gitignore | 1 + wear/build.gradle.kts | 69 +++++++++++ wear/lint.xml | 8 ++ wear/proguard-rules.pro | 21 ++++ wear/src/main/AndroidManifest.xml | 35 ++++++ .../org/tasks/presentation/MainActivity.kt | 112 ++++++++++++++++++ .../presentation/MainActivityViewModel.kt | 4 + .../presentation/screens/TaskListScreen.kt | 94 +++++++++++++++ .../presentation/screens/TaskListViewModel.kt | 54 +++++++++ .../org/tasks/presentation/theme/Theme.kt | 17 +++ .../main/res/mipmap-anydpi/ic_launcher.xml | 6 + wear/src/main/res/values-round/strings.xml | 3 + wear/src/main/res/values/strings.xml | 8 ++ wear/src/main/res/values/styles.xml | 8 ++ wear/src/main/res/values/wear.xml | 7 ++ 31 files changed, 769 insertions(+), 4 deletions(-) create mode 100644 app/src/googleplay/java/org/tasks/wear/WearDataService.kt create mode 100644 app/src/googleplay/java/org/tasks/wear/WearService.kt create mode 100644 app/src/googleplay/res/values/wear.xml create mode 100644 wear-datalayer/.gitignore create mode 100644 wear-datalayer/build.gradle.kts create mode 100644 wear-datalayer/consumer-rules.pro create mode 100644 wear-datalayer/proguard-rules.pro create mode 100644 wear-datalayer/src/main/AndroidManifest.xml create mode 100644 wear-datalayer/src/main/java/org/tasks/wear/TasksSerializer.kt create mode 100644 wear-datalayer/src/main/proto/grpc.proto create mode 100644 wear/.gitignore create mode 100644 wear/build.gradle.kts create mode 100644 wear/lint.xml create mode 100644 wear/proguard-rules.pro create mode 100644 wear/src/main/AndroidManifest.xml create mode 100644 wear/src/main/java/org/tasks/presentation/MainActivity.kt create mode 100644 wear/src/main/java/org/tasks/presentation/MainActivityViewModel.kt create mode 100644 wear/src/main/java/org/tasks/presentation/screens/TaskListScreen.kt create mode 100644 wear/src/main/java/org/tasks/presentation/screens/TaskListViewModel.kt create mode 100644 wear/src/main/java/org/tasks/presentation/theme/Theme.kt create mode 100644 wear/src/main/res/mipmap-anydpi/ic_launcher.xml create mode 100644 wear/src/main/res/values-round/strings.xml create mode 100644 wear/src/main/res/values/strings.xml create mode 100644 wear/src/main/res/values/styles.xml create mode 100644 wear/src/main/res/values/wear.xml 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