diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 0000000..c9d00de --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,81 @@ +name: Build Android App (apk) + +on: + push: + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout code and submodule(s) + uses: actions/checkout@v4 + with: + submodules: 'recursive' + + # Set Current Date As Env Variable + - name: Set current date as env variable + run: echo "date_today=$(date +'%Y-%m-%d')" >> $GITHUB_ENV + + # Set Repository Name As Env Variable + - name: Set repository name as env variable + run: echo "repository_name=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV + + - name: Set Up JDK + uses: actions/setup-java@v4 + with: + distribution: 'zulu' # See 'Supported distributions' for available options + java-version: '21' + cache: 'gradle' + + - name: Change wrapper permissions + run: chmod +x ./gradlew + + # Run Build Project + - name: Build gradle project + run: ./gradlew build + + # Run Tests Build + - name: Run gradle tests + run: ./gradlew test + + # Create APK Debug + - name: Build apk debug project (APK) + run: ./gradlew assembleDebug + + # Create APK Release + - name: Build apk release project (APK) + run: ./gradlew assembleRelease + + # Upload Artifact Build + # Noted For Output mobile/build/outputs/apk/debug/ + - name: Upload mobile APK Debug - ${{ env.repository_name }} + uses: actions/upload-artifact@v4 + with: + name: ${{ env.date_today }} - ${{ env.repository_name }} - mobile - APK(s) debug generated + path: mobile/build/outputs/apk/debug/ + + # Noted For Output mobile/build/outputs/apk/release/ + - name: Upload mobile APK Release - ${{ env.repository_name }} + uses: actions/upload-artifact@v4 + with: + name: ${{ env.date_today }} - ${{ env.repository_name }} - mobile - APK(s) release generated + path: mobile/build/outputs/apk/release/ + + # Noted For Output wear/build/outputs/apk/debug/ + - name: Upload wear APK Debug - ${{ env.repository_name }} + uses: actions/upload-artifact@v4 + with: + name: ${{ env.date_today }} - ${{ env.repository_name }} - wear - APK(s) debug generated + path: wear/build/outputs/apk/debug/ + + # Noted For Output wear/build/outputs/apk/release/ + - name: Upload wear APK Release - ${{ env.repository_name }} + uses: actions/upload-artifact@v4 + with: + name: ${{ env.date_today }} - ${{ env.repository_name }} - wear - APK(s) release generated + path: wear/build/outputs/apk/release/ diff --git a/.gitignore b/.gitignore index aa724b7..9c87163 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,6 @@ -*.iml -.gradle +/.gradle +/.idea +/.kotlin +/build /local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml .DS_Store -/build -/captures -.externalNativeBuild -.cxx -local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/.name b/.idea/.name deleted file mode 100644 index b5a316e..0000000 --- a/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -Phone Wear Remote \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index b86273d..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index edc4047..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml deleted file mode 100644 index 6d0ee1c..0000000 --- a/.idea/kotlinc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml deleted file mode 100644 index f8051a6..0000000 --- a/.idea/migrations.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 74dd639..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 16660f1..0000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 84b18a7..a6bcd2b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.8.0" -kotlin = "2.0.0" +kotlin = "2.0.21" coreKtx = "1.15.0" junit = "4.13.2" junitVersion = "1.2.1" @@ -23,6 +23,7 @@ tilesToolingPreview = "1.4.0" horologistComposeTools = "0.6.17" horologistTiles = "0.6.17" watchfaceComplicationsDataSourceKtx = "1.2.1" +wearPhoneInteractions = "1.0.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -53,6 +54,8 @@ androidx-tiles-tooling-preview = { group = "androidx.wear.tiles", name = "tiles- horologist-compose-tools = { group = "com.google.android.horologist", name = "horologist-compose-tools", version.ref = "horologistComposeTools" } horologist-tiles = { group = "com.google.android.horologist", name = "horologist-tiles", version.ref = "horologistTiles" } androidx-watchface-complications-data-source-ktx = { group = "androidx.wear.watchface", name = "watchface-complications-data-source-ktx", version.ref = "watchfaceComplicationsDataSourceKtx" } +androidx-wear-phone-interactions = { group = "androidx.wear", name = "wear-phone-interactions", version.ref = "wearPhoneInteractions" } +kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1e448c1..2c52d33 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Thu Jan 23 19:43:43 PST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/mobile/build.gradle.kts b/mobile/build.gradle.kts index 6c8fd63..cd93e58 100644 --- a/mobile/build.gradle.kts +++ b/mobile/build.gradle.kts @@ -8,9 +8,9 @@ android { compileSdk = 35 defaultConfig { - applicationId = "com.swooby.phonewearremote" minSdk = 34 - targetSdk = 35 + + applicationId = "com.swooby.phonewearremote" versionCode = 1 versionName = "1.0" @@ -27,11 +27,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "21" } } @@ -45,5 +45,4 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) - wearApp(project(":wear")) -} \ No newline at end of file +} diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index 5afc1c0..a743f15 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -3,7 +3,6 @@ xmlns:tools="http://schemas.android.com/tools"> + > + android:exported="true" + > @@ -23,4 +23,4 @@ - \ No newline at end of file + diff --git a/mobile/src/main/java/com/swooby/phonewearremote/MainActivity.kt b/mobile/src/main/java/com/swooby/phonewearremote/MainActivity.kt index 4ae665a..cf06aa1 100644 --- a/mobile/src/main/java/com/swooby/phonewearremote/MainActivity.kt +++ b/mobile/src/main/java/com/swooby/phonewearremote/MainActivity.kt @@ -1,12 +1,23 @@ package com.swooby.phonewearremote import android.os.Bundle +import android.util.Log import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Wearable +import com.swooby.phonewearremote.Utils.quote + +class MainActivity : AppCompatActivity(), MessageClient.OnMessageReceivedListener { + companion object { + private const val TAG = "MainActivity" + } + + private val messageClient by lazy { Wearable.getMessageClient(this) } -class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -16,5 +27,30 @@ class MainActivity : AppCompatActivity() { v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) insets } + + messageClient.addListener(this) + } + + override fun onDestroy() { + super.onDestroy() + messageClient.removeListener(this) + } + + override fun onMessageReceived(messageEvent: MessageEvent) { + val path = messageEvent.path + when (path) { + "/pushToTalk" -> handlePushToTalkCommand(messageEvent) + else -> Log.d(TAG, "Unhandled messageEvent.path=${quote(path)}") + } + } + + private fun handlePushToTalkCommand(messageEvent: MessageEvent) { + val payload = messageEvent.data + val payloadString = String(payload) + Log.i(TAG, "handlePushToTalkCommand: PushToTalk command received! payloadString=${quote(payloadString)}") + + // TODO: Start your "PushToTalk" action: + // e.g., open a microphone, start voice recognition, etc. + // ... } } \ No newline at end of file diff --git a/mobile/src/main/java/com/swooby/phonewearremote/Utils.kt b/mobile/src/main/java/com/swooby/phonewearremote/Utils.kt new file mode 100644 index 0000000..78c4313 --- /dev/null +++ b/mobile/src/main/java/com/swooby/phonewearremote/Utils.kt @@ -0,0 +1,32 @@ +package com.swooby.phonewearremote + +import kotlin.reflect.KClass + +object Utils { + fun getShortClassName(value: Any?): String { + return when (value) { + is KClass<*> -> value.simpleName ?: "null" + else -> value?.javaClass?.simpleName ?: "null" + } + } + + fun quote(value: Any?, typeOnly: Boolean = false): String { + if (value == null) { + return "null" + } + + if (typeOnly) { + return getShortClassName(value) + } + + if (value is String) { + return "\"$value\"" + } + + if (value is CharSequence) { + return "\"$value\"" + } + + return value.toString() + } +} \ No newline at end of file diff --git a/mobile/src/main/res/values/mobile.xml b/mobile/src/main/res/values/mobile.xml new file mode 100644 index 0000000..07963a0 --- /dev/null +++ b/mobile/src/main/res/values/mobile.xml @@ -0,0 +1,9 @@ + + + + verify_remote_example_mobile_app + + \ No newline at end of file diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 8ab3ca5..7a9164c 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -9,9 +9,9 @@ android { compileSdk = 35 defaultConfig { - applicationId = "com.swooby.phonewearremote" minSdk = 34 - targetSdk = 35 + + applicationId = "com.swooby.phonewearremote" versionCode = 1 versionName = "1.0" @@ -27,11 +27,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "21" } buildFeatures { compose = true @@ -55,10 +55,12 @@ dependencies { implementation(libs.horologist.compose.tools) implementation(libs.horologist.tiles) implementation(libs.androidx.watchface.complications.data.source.ktx) + implementation(libs.androidx.wear.phone.interactions) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.ui.test.junit4) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) debugImplementation(libs.androidx.tiles.tooling) - wearApp(project(":wear")) -} \ No newline at end of file + + implementation(libs.kotlin.reflect) +} diff --git a/wear/src/main/AndroidManifest.xml b/wear/src/main/AndroidManifest.xml index 16b10e0..3c2aaa4 100644 --- a/wear/src/main/AndroidManifest.xml +++ b/wear/src/main/AndroidManifest.xml @@ -1,12 +1,14 @@ + + - - \ No newline at end of file + diff --git a/wear/src/main/java/com/swooby/phonewearremote/Utils.kt b/wear/src/main/java/com/swooby/phonewearremote/Utils.kt new file mode 100644 index 0000000..2cdcfd7 --- /dev/null +++ b/wear/src/main/java/com/swooby/phonewearremote/Utils.kt @@ -0,0 +1,69 @@ +package com.swooby.phonewearremote + +import androidx.wear.phone.interactions.PhoneTypeHelper +import kotlin.reflect.KClass +import kotlin.reflect.full.companionObject +import kotlin.reflect.full.memberProperties +import kotlin.reflect.full.staticProperties + +object Utils { + /** + * Creates a map of a class' field values to names + */ + fun getMapOfIntFieldsToNames(clazz: KClass<*>, startsWith: String?): Map { + val staticProperties = clazz.staticProperties + if (staticProperties.isEmpty()) { + val companion = clazz.companionObject + val companionInstance = companion?.objectInstance + if (companion != null && companionInstance != null) { + return companion.memberProperties.filter { + (it.returnType.classifier == Int::class) && // Check if the property type is Int + (startsWith.isNullOrBlank() || it.name.startsWith(startsWith)) + }.associate { + it.getter.call(companionInstance) as Int to it.name + } + } + } + return staticProperties.filter { + (it.returnType.classifier == Integer::class) && + (startsWith.isNullOrBlank() || + it.name.startsWith(startsWith)) + }.associate { + it.getter.call() as Int to it.name + } + } + + fun phoneDeviceTypeToString(phoneType: Int): String { + val map = getMapOfIntFieldsToNames( + PhoneTypeHelper::class, + "DEVICE_TYPE_") + return (map[phoneType] ?: "INVALID") + "($phoneType)" + } + + fun getShortClassName(value: Any?): String { + return when (value) { + is KClass<*> -> value.simpleName ?: "null" + else -> value?.javaClass?.simpleName ?: "null" + } + } + + fun quote(value: Any?, typeOnly: Boolean = false): String { + if (value == null) { + return "null" + } + + if (typeOnly) { + return getShortClassName(value) + } + + if (value is String) { + return "\"$value\"" + } + + if (value is CharSequence) { + return "\"$value\"" + } + + return value.toString() + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/swooby/phonewearremote/WearViewModel.kt b/wear/src/main/java/com/swooby/phonewearremote/WearViewModel.kt new file mode 100644 index 0000000..c6d9a55 --- /dev/null +++ b/wear/src/main/java/com/swooby/phonewearremote/WearViewModel.kt @@ -0,0 +1,91 @@ +package com.swooby.phonewearremote + +import android.app.Application +import android.util.Log +import androidx.lifecycle.AndroidViewModel +import com.google.android.gms.tasks.Task +import com.google.android.gms.wearable.CapabilityClient +import com.google.android.gms.wearable.MessageClient +import com.google.android.gms.wearable.MessageEvent +import com.google.android.gms.wearable.Node +import com.google.android.gms.wearable.Wearable +import com.swooby.phonewearremote.Utils.quote +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class WearViewModel(application: Application) : + AndroidViewModel(application) { + companion object { + private const val TAG = "WearViewModel" + } + + // setter + private var _phoneAppNodeId = MutableStateFlow(null) + // getter + val phoneAppNodeId = _phoneAppNodeId.asStateFlow() + + private val capabilityClient by lazy { Wearable.getCapabilityClient(application) } + + private val messageClient by lazy { Wearable.getMessageClient(application) } + private val messageClientListener = MessageClient.OnMessageReceivedListener { + onMessageClientMessageReceived(it) + } + + fun init() { + messageClient.addListener(messageClientListener) + + val capabilityClientTask = capabilityClient.getCapability( + "verify_remote_example_mobile_app", + CapabilityClient.FILTER_REACHABLE + ) + capabilityClientTask.addOnCompleteListener { + if (it.isSuccessful) { + Log.d(TAG, "Capability request succeeded"); + _phoneAppNodeId.value = pickFirstNearbyNode(it.result.nodes)?.id + if (phoneAppNodeId.value == null) { + Log.d(TAG, "Detected no phone app node") + } else { + Log.i(TAG, "Detected phoneAppNodeId=${quote(phoneAppNodeId.value)}") + } + } else { + Log.e(TAG, "Capability request failed to return any results"); + } + } + } + + fun close() { + messageClient.removeListener(messageClientListener) + } + + private fun onMessageClientMessageReceived(messageEvent: MessageEvent) { + val path = messageEvent.path + val payload = String(messageEvent.data, Charsets.UTF_8) + // Handle incoming message from watch/phone + Log.d(TAG, "onMessageReceived: $path, $payload") + } + + private fun pickFirstNearbyNode(nodes: Set): Node? { + return nodes.firstOrNull { it.isNearby } ?: nodes.firstOrNull() + } + + private fun sendMessageToNode(nodeId: String, path: String, data: String): Task { + return sendMessageToNode(nodeId, path, data.toByteArray()) + } + + private fun sendMessageToNode(nodeId: String, path: String, data: ByteArray): Task { + return messageClient + .sendMessage(nodeId, path, data) + .addOnSuccessListener { + Log.d(TAG, "sendMessageToNode: Message sent successfully to $nodeId") + } + .addOnFailureListener { e -> + Log.e(TAG, "sendMessageToNode: Message failed.", e) + } + } + + fun sendPushToTalkCommand(nodeId: String, on: Boolean): Task { + val path = "/pushToTalk" + val payload = (if (on) "on" else "off") + return sendMessageToNode(nodeId, path, payload) + } +} \ No newline at end of file diff --git a/wear/src/main/java/com/swooby/phonewearremote/presentation/MainActivity.kt b/wear/src/main/java/com/swooby/phonewearremote/presentation/MainActivity.kt index 7274a7d..3d0d883 100644 --- a/wear/src/main/java/com/swooby/phonewearremote/presentation/MainActivity.kt +++ b/wear/src/main/java/com/swooby/phonewearremote/presentation/MainActivity.kt @@ -6,42 +6,82 @@ package com.swooby.phonewearremote.presentation import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.activity.viewModels import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.wear.compose.material.MaterialTheme import androidx.wear.compose.material.Text import androidx.wear.compose.material.TimeText import androidx.wear.tooling.preview.devices.WearDevices -import com.swooby.phonewearremote.R +import com.swooby.phonewearremote.WearViewModel import com.swooby.phonewearremote.presentation.theme.PhoneWearRemoteTheme class MainActivity : ComponentActivity() { + + private val wearViewModel: WearViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() - super.onCreate(savedInstanceState) - setTheme(android.R.style.Theme_DeviceDefault) - setContent { - WearApp("Android") + WearApp("Wear", wearViewModel) } } + + override fun onResume() { + super.onResume() + wearViewModel.init() + } + + override fun onPause() { + super.onPause() + wearViewModel.close() + } +} + +@Preview(device = WearDevices.SMALL_ROUND, showSystemUi = true) +@Composable +fun DefaultPreview() { + WearApp("Preview") } @Composable -fun WearApp(greetingName: String) { +fun WearApp( + targetNameDefault: String, + wearViewModel: WearViewModel? = null, +) { + @Suppress("LocalVariableName") + val TAG = "WearApp" + + val phoneAppNodeId by wearViewModel?.phoneAppNodeId?.collectAsState() ?: remember { mutableStateOf(null) } + PhoneWearRemoteTheme { Box( modifier = Modifier @@ -50,23 +90,115 @@ fun WearApp(greetingName: String) { contentAlignment = Alignment.Center ) { TimeText() - Greeting(greetingName = greetingName) + RemoteButton( + targetName = if (phoneAppNodeId != null) "Phone" else targetNameDefault, + onPushToTalkStart = { + @Suppress("NAME_SHADOWING") + val phoneAppNodeId = phoneAppNodeId + if (phoneAppNodeId != null) { + Log.d(TAG, "onClick: PTT on phone...") + wearViewModel?.sendPushToTalkCommand(phoneAppNodeId, true) + } else { + Log.d(TAG, "onClick: TODO: PTT on local ...") + // ... + } + true + }, + onPushToTalkStop = { + @Suppress("NAME_SHADOWING") + val phoneAppNodeId = phoneAppNodeId + if (phoneAppNodeId != null) { + Log.d(TAG, "onClick: PTT off phone...") + wearViewModel?.sendPushToTalkCommand(phoneAppNodeId, false) + } else { + Log.d(TAG, "onClick: TODO: PTT off local ...") + // ... + } + true + }, + ) } } } -@Composable -fun Greeting(greetingName: String) { - Text( - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - color = MaterialTheme.colors.primary, - text = stringResource(R.string.hello_world, greetingName) - ) +enum class PTTState { + Idle, + Pressed } -@Preview(device = WearDevices.SMALL_ROUND, showSystemUi = true) @Composable -fun DefaultPreview() { - WearApp("Preview Android") -} \ No newline at end of file +fun RemoteButton( + targetName: String, + enabled: Boolean = true, + onPushToTalkStart: (pttState: PTTState) -> Boolean = { + Log.d("PTT", "Push-to-Talk Start") + false + }, + onPushToTalkStop: (pttState: PTTState) -> Boolean = { + Log.d("PTT", "Push-to-Talk Stop") + false + } +) { + var pttState by remember { mutableStateOf(PTTState.Idle) } + + val disabledColor = MaterialTheme.colors.onSurface.copy(alpha = 0.38f) + val boxAlpha = if (enabled) 1.0f else 0.38f + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(120.dp) + .border(4.dp, if (enabled) MaterialTheme.colors.primary else disabledColor, shape = CircleShape) + .background( + color = if (pttState == PTTState.Pressed) MaterialTheme.colors.primary else MaterialTheme.colors.surface, + shape = CircleShape + ) + .let { baseModifier -> + if (enabled) { + baseModifier.pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown() + if (pttState == PTTState.Idle) { + pttState = PTTState.Pressed + if (!onPushToTalkStart(pttState)) { + //provideHapticFeedback(context) + //provideAudibleFeedback(context, pttState) + } + } + do { + val event = awaitPointerEvent() + } while (event.changes.any { !it.changedToUp() }) + if (pttState == PTTState.Pressed) { + pttState = PTTState.Idle + if (!onPushToTalkStop(pttState)) { + //provideHapticFeedback(context) + //provideAudibleFeedback(context, pttState) + } + } + } + } + } else { + baseModifier.pointerInput(Unit) { + awaitEachGesture { + while (true) { + val event = awaitPointerEvent() + event.changes.forEach { it.consume() } + } + } + } + } + } + .then(Modifier.background(Color.Transparent)) + .let { + it.background(Color.Transparent).graphicsLayer { + this.alpha = boxAlpha + } + }, + ) { + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = targetName + ) + } +} diff --git a/wear/src/main/res/values/wear.xml b/wear/src/main/res/values/wear.xml new file mode 100644 index 0000000..5565869 --- /dev/null +++ b/wear/src/main/res/values/wear.xml @@ -0,0 +1,9 @@ + + + + verify_remote_example_wear_app + + \ No newline at end of file