Skip to content

Commit

Permalink
Update Wear to use app and tile status helpers (1 of 2) (#1205)
Browse files Browse the repository at this point in the history
  • Loading branch information
yschimke authored Mar 24, 2024
1 parent 9f7784e commit 155092d
Show file tree
Hide file tree
Showing 14 changed files with 108 additions and 145 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/fixes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
id: screenshotsverify
continue-on-error: true

- name: LFS workaround
run: git config lfs.locksverify false

- name: Commit Screenshots
uses: 'stefanzweifel/git-auto-commit-action@v5'
if: steps.screenshotsverify.outcome == 'failure'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ val appModule = module {
DefaultSettingsComponent(
componentContext = DefaultComponentContext(lifecycle),
appSettings = get(),
wearSettingsSync = get(),
authentication = get(),
).also { lifecycle.resume() }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.Button
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Divider
Expand Down Expand Up @@ -50,7 +49,6 @@ import dev.johnoreilly.confetti.DeveloperSettings
import dev.johnoreilly.confetti.R
import dev.johnoreilly.confetti.ThemeBrand
import dev.johnoreilly.confetti.UserEditableSettings
import dev.johnoreilly.confetti.WearStatus
import dev.johnoreilly.confetti.decompose.SettingsComponent
import dev.johnoreilly.confetti.ui.supportsDynamicTheming

Expand All @@ -65,7 +63,6 @@ fun SettingsRoute(
onChangeThemeBrand = component::updateThemeBrand,
onChangeDynamicColorPreference = component::updateDynamicColorPreference,
onChangeDarkThemeConfig = component::updateDarkThemeConfig,
onInstallOnWatch = component::installOnWatch,
onChangeUseExperimentalFeatures = component::updateUseExperimentalFeatures,
developerSettings = developerSettings,
onEnableDeveloperMode = component::enableDeveloperMode
Expand All @@ -80,7 +77,6 @@ fun SettingsScreen(
onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,
onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
onInstallOnWatch: (String) -> Unit,
developerSettings: DeveloperSettings?,
onEnableDeveloperMode: () -> Unit
) {
Expand Down Expand Up @@ -130,25 +126,6 @@ fun SettingsScreen(
}
}

item {
Row(
modifier = Modifier
.padding(top = 16.dp, start = 8.dp, end = 8.dp),
) {
when (val wearStatus = userEditableSettings?.wearStatus) {
is WearStatus.NotInstalled -> {
Button(onClick = { onInstallOnWatch(wearStatus.nodeId) }) {
Text(stringResource(id = R.string.install_on_watch))
}
}

else -> {
Text(stringResource(id = R.string.no_paired_watch))
}
}
}
}

item {
val context = LocalContext.current
Spacer(modifier = Modifier.height(8.dp))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,16 @@
package dev.johnoreilly.confetti.settings

import com.arkivanov.decompose.ComponentContext
import com.google.android.horologist.data.apphelper.AppHelperNodeStatus
import com.google.android.horologist.data.apphelper.AppInstallationStatus
import com.russhwolf.settings.ExperimentalSettingsApi
import dev.johnoreilly.confetti.AppSettings
import dev.johnoreilly.confetti.DarkThemeConfig
import dev.johnoreilly.confetti.DeveloperSettings
import dev.johnoreilly.confetti.ThemeBrand
import dev.johnoreilly.confetti.UserEditableSettings
import dev.johnoreilly.confetti.WearStatus
import dev.johnoreilly.confetti.auth.Authentication
import dev.johnoreilly.confetti.decompose.SettingsComponent
import dev.johnoreilly.confetti.decompose.coroutineScope
import dev.johnoreilly.confetti.wear.WearSettingsSync
import dev.johnoreilly.confetti.wear.proto.WearSettings
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -30,21 +26,12 @@ import kotlinx.coroutines.launch
class DefaultSettingsComponent(
componentContext: ComponentContext,
private val appSettings: AppSettings,
private val wearSettingsSync: WearSettingsSync,
private val authentication: Authentication,
) : SettingsComponent, ComponentContext by componentContext {

private val coroutineScope = coroutineScope()
private val settings = appSettings.settings

private val wearStatusFlow =
combine(
wearSettingsSync.wearNodes,
wearSettingsSync.settingsFlow,
) { wearNodes, wearSettings ->
buildWearStatus(wearNodes, wearSettings)
}

override val developerSettings: StateFlow<DeveloperSettings?> = appSettings.developerModeFlow().flatMapLatest {
if (!it) {
flowOf(null)
Expand All @@ -65,34 +52,19 @@ class DefaultSettingsComponent(
settings.getStringFlow(darkThemeConfigKey, DarkThemeConfig.FOLLOW_SYSTEM.toString()),
settings.getBooleanFlow(useDynamicColorKey, false),
appSettings.experimentalFeaturesEnabledFlow,
wearStatusFlow,
) { themeBrand, darkThemeConfig, useDynamicColor, useExperimentalFeatures, wearStatus ->
) { themeBrand, darkThemeConfig, useDynamicColor, useExperimentalFeatures ->
UserEditableSettings(
brand = ThemeBrand.valueOf(themeBrand),
useExperimentalFeatures = useExperimentalFeatures,
useDynamicColor = useDynamicColor,
darkThemeConfig = DarkThemeConfig.valueOf(darkThemeConfig),
wearStatus = wearStatus,
)
}.stateIn(
scope = coroutineScope,
started = SharingStarted.Eagerly,
initialValue = null,
)

private fun buildWearStatus(
wearNodes: List<AppHelperNodeStatus>,
wearSettings: WearSettings
): WearStatus {
return if (wearNodes.isEmpty()) {
WearStatus.Unavailable
} else if (wearNodes.find { it.appInstallationStatus is AppInstallationStatus.Installed } == null) {
WearStatus.NotInstalled(wearNodes.first().id)
} else {
WearStatus.Paired(wearSettings)
}
}

override fun updateThemeBrand(themeBrand: ThemeBrand) {
coroutineScope.launch {
settings.putString(brandKey, themeBrand.toString())
Expand All @@ -117,12 +89,6 @@ class DefaultSettingsComponent(
}
}

override fun installOnWatch(nodeId: String) {
coroutineScope.launch {
wearSettingsSync.installOnWearNode(nodeId)
}
}


override fun enableDeveloperMode() {
coroutineScope.launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,91 +2,22 @@

package dev.johnoreilly.confetti.wear

import android.os.Build
import android.util.Log
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.api.AvailabilityException
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.data.ProtoDataStoreHelper.protoDataStore
import com.google.android.horologist.data.WearDataLayerRegistry
import com.google.android.horologist.datalayer.phone.PhoneDataLayerAppHelper
import dev.johnoreilly.confetti.wear.proto.WearSettings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.tasks.await

class WearSettingsSync(
val dataLayerRegistry: WearDataLayerRegistry,
val phoneDataLayerRegistry: PhoneDataLayerAppHelper,
val coroutineScope: CoroutineScope
) {
private var job: Job? = null

val settingsDataStore by lazy { dataLayerRegistry.protoDataStore<WearSettings>(coroutineScope) }

suspend fun isAvailable(): Boolean {
if (Build.VERSION.SDK_INT < 23)
return false

return try {
GoogleApiAvailability.getInstance()
.checkApiAvailability(dataLayerRegistry.dataClient)
.await()

true
} catch (e: AvailabilityException) {
Log.d(
"WearSettingsSync",
"DataClient API is not available in this device. WearSettingsSync will fail silently and all functionality will be no-op."
)
false
}
}

val settingsFlow: Flow<WearSettings> = flow {
if (isAvailable()) {
emitAll(settingsDataStore.data)
} else {
emit(WearSettings())
}
}.catch {
emit(WearSettings())
}

val wearNodes = flow {
emit(phoneDataLayerRegistry.connectedNodes())
}.catch { emit(listOf()) }

// public non-suspend function to be called from button callbacks, etc...
fun installOnWearNode(nodeId: String) {
if (job != null) {
// already installing, skip
return
}

// coroutineScope at this point is a app-global scope
job = coroutineScope.launch {
installOnWearNodeInternal(nodeId)
job = null
}
}

private suspend fun installOnWearNodeInternal(nodeId: String) {
try {
phoneDataLayerRegistry.installOnNode(nodeId)
} catch (rie: Exception) {
// likely RemoteIntentException due to emulator without playstore
// TODO handle error
}
}

suspend fun setConference(conference: String, colorScheme: String?) {
if (isAvailable()) {
if (phoneDataLayerRegistry.isAvailable()) {
settingsDataStore.updateData {
it.copy(conference = conference, color_scheme = colorScheme)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package dev.johnoreilly.confetti

import dev.johnoreilly.confetti.wear.proto.WearSettings

data class DeveloperSettings(
val token: String?
)
Expand All @@ -14,17 +12,8 @@ data class UserEditableSettings(
val useDynamicColor: Boolean,
val darkThemeConfig: DarkThemeConfig,
val useExperimentalFeatures: Boolean,
val wearStatus: WearStatus
)

sealed interface WearStatus {
object Unavailable : WearStatus
data class NotInstalled(val nodeId: String) : WearStatus
data class Paired(
val wearSettings: WearSettings
) : WearStatus
}

enum class ThemeBrand {
DEFAULT, ANDROID
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,5 @@ actual interface SettingsComponent {
fun updateDarkThemeConfig(darkThemeConfig: DarkThemeConfig)
fun updateDynamicColorPreference(useDynamicColor: Boolean)
fun updateUseExperimentalFeatures(value: Boolean)
fun installOnWatch(nodeId: String)
fun enableDeveloperMode()
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,41 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.LaunchedEffect
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import com.arkivanov.decompose.defaultComponentContext
import com.google.firebase.FirebaseApp
import dev.johnoreilly.confetti.analytics.AnalyticsLogger
import dev.johnoreilly.confetti.wear.navigation.DefaultWearAppComponent
import dev.johnoreilly.confetti.wear.navigation.NavigationHelper.logNavigationEvent
import dev.johnoreilly.confetti.wear.navigation.WearAppComponent
import dev.johnoreilly.confetti.wear.tile.TileSync
import dev.johnoreilly.confetti.wear.ui.ConfettiApp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.android.ext.android.inject
import org.koin.androidx.compose.KoinAndroidContext
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.core.annotation.KoinInternalApi
import kotlin.time.Duration.Companion.seconds

class MainActivity : ComponentActivity() {
internal lateinit var appComponent: WearAppComponent
private val analyticsLogger: AnalyticsLogger by inject()

private val tileSync: TileSync by inject()

override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()

super.onCreate(savedInstanceState)

// Schedule an update of tiles if user keeps apps open for 5 seconds
lifecycleScope.launch {
delay(5.seconds)

tileSync.trackInstalledTiles(this@MainActivity)
}

appComponent =
DefaultWearAppComponent(
componentContext = defaultComponentContext(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package dev.johnoreilly.confetti.wear.complication
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_IMMUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import androidx.wear.watchface.complications.data.ComplicationType
import androidx.wear.watchface.complications.datasource.ComplicationRequest
import com.apollographql.apollo3.cache.normalized.FetchPolicy
import com.google.android.horologist.datalayer.watch.WearDataLayerAppHelper
import com.google.android.horologist.tiles.complication.DataComplicationService
import dev.johnoreilly.confetti.ConfettiRepository
import dev.johnoreilly.confetti.auth.Authentication
Expand All @@ -16,6 +18,7 @@ import dev.johnoreilly.confetti.toTimeZone
import dev.johnoreilly.confetti.wear.MainActivity
import dev.johnoreilly.confetti.wear.settings.PhoneSettingsSync
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.toKotlinInstant
import kotlinx.datetime.toLocalDateTime
import org.koin.android.ext.android.inject
Expand All @@ -31,6 +34,20 @@ class NextSessionComplicationService :

private val authentication: Authentication by inject()

private val wearAppHelper: WearDataLayerAppHelper by inject()

override fun onComplicationActivated(complicationInstanceId: Int, type: ComplicationType) {
runBlocking {
wearAppHelper.markComplicationAsActivated(this@NextSessionComplicationService.javaClass.simpleName, complicationInstanceId, type)
}
}

override fun onComplicationDeactivated(complicationInstanceId: Int) {
runBlocking {
wearAppHelper.markComplicationAsDeactivated(this@NextSessionComplicationService.javaClass.simpleName, complicationInstanceId, ComplicationType.EMPTY)
}
}

override suspend fun data(request: ComplicationRequest): NextSessionComplicationData {
val conference = phoneSettingsSync.conferenceFlow.first().conference
val user = authentication.currentUser.value
Expand Down
Loading

0 comments on commit 155092d

Please sign in to comment.