diff --git a/app-wear/build.gradle.kts b/app-wear/build.gradle.kts
index 2e09b70c..5d6df4d8 100644
--- a/app-wear/build.gradle.kts
+++ b/app-wear/build.gradle.kts
@@ -111,11 +111,11 @@ android {
kotlinOptions {
jvmTarget = "1.8"
freeCompilerArgs = freeCompilerArgs + listOf(
- "-Xopt-in=kotlin.ExperimentalStdlibApi",
- "-Xopt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
- "-Xopt-in=kotlinx.coroutines.FlowPreview",
- "-Xopt-in=kotlin.time.ExperimentalTime",
- "-Xopt-in=kotlin.RequiresOptIn"
+ "-opt-in=kotlin.ExperimentalStdlibApi",
+ "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
+ "-opt-in=kotlinx.coroutines.FlowPreview",
+ "-opt-in=kotlin.time.ExperimentalTime",
+ "-opt-in=kotlin.RequiresOptIn"
)
}
@@ -140,30 +140,28 @@ android {
}
dependencies {
- implementation(project(":app-common"))
-
- implementation("androidx.wear:wear:1.2.0")
- implementation("androidx.wear.tiles:tiles-material:1.1.0")
- implementation("com.google.android.horologist:horologist-tiles:0.1.5")
- implementation("com.google.android.gms:play-services-wearable:17.1.0")
-
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
+ implementation(project(":app-common"))
+
addBaseKotlin()
addDagger()
- // Debugging
- implementation("com.bugsnag:bugsnag-android:5.9.2")
- implementation("com.getkeepsafe.relinker:relinker:1.4.3")
-
- implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
-
addBaseWorkManager()
addBaseAndroid()
addBaseAndroidUi()
- implementation("com.google.android.material:material:1.5.0-rc01")
+
+ addNavigation()
addTesting()
+
+ implementation("com.bugsnag:bugsnag-android:5.9.2")
+ implementation("com.getkeepsafe.relinker:relinker:1.4.3")
+
+ implementation("androidx.wear:wear:1.2.0")
+ implementation("androidx.wear.tiles:tiles-material:1.1.0")
+ implementation("com.google.android.horologist:horologist-tiles:0.1.5")
+ implementation("com.google.android.gms:play-services-wearable:17.1.0")
}
\ No newline at end of file
diff --git a/app-wear/src/main/AndroidManifest.xml b/app-wear/src/main/AndroidManifest.xml
index bc67a4b9..67ecc46f 100644
--- a/app-wear/src/main/AndroidManifest.xml
+++ b/app-wear/src/main/AndroidManifest.xml
@@ -3,6 +3,26 @@
package="eu.darken.capod">
+
+
+
+
+
+
+
+
+
+
@@ -11,6 +31,7 @@
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
+ android:name=".App"
android:theme="@android:style/Theme.DeviceDefault">
-
log(TAG) { "ReLinker: $message" } }
+ .loadLibrary(this, "bugsnag-plugin-android-anr")
+
+ autoReporting.setup()
+
+ log(TAG) { "onCreate() done! ${Exception().asLog()}" }
+ }
+
+ override fun getWorkManagerConfiguration(): Configuration = Configuration.Builder()
+ .setMinimumLoggingLevel(android.util.Log.VERBOSE)
+ .setWorkerFactory(workerFactory)
+ .build()
+
+ companion object {
+ internal val TAG = logTag("CAP")
+ }
+}
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/MainActivity.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/MainActivity.kt
index 24a9759f..1a0ce1ba 100644
--- a/app-wear/src/main/java/eu/darken/capod/wear/ui/MainActivity.kt
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/MainActivity.kt
@@ -1,17 +1,27 @@
package eu.darken.capod.wear.ui
-import android.app.Activity
import android.os.Bundle
-import eu.darken.capod.databinding.ActivityMainBinding
+import androidx.activity.viewModels
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import dagger.hilt.android.AndroidEntryPoint
+import eu.darken.capod.R
+import eu.darken.capod.common.navigation.findNavController
+import eu.darken.capod.common.uix.Activity2
+import eu.darken.capod.databinding.MainActivityBinding
-class MainActivity : Activity() {
+@AndroidEntryPoint
+class MainActivity : Activity2() {
- private lateinit var binding: ActivityMainBinding
+ private val vm: MainActivityVM by viewModels()
+ private lateinit var ui: MainActivityBinding
+ private val navController by lazy { supportFragmentManager.findNavController(R.id.nav_host) }
override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+
super.onCreate(savedInstanceState)
- binding = ActivityMainBinding.inflate(layoutInflater)
- setContentView(binding.root)
+ ui = MainActivityBinding.inflate(layoutInflater)
+ setContentView(ui.root)
}
-}
\ No newline at end of file
+}
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/MainActivityVM.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/MainActivityVM.kt
index 4ae95b6a..6bfb929d 100644
--- a/app-wear/src/main/java/eu/darken/capod/wear/ui/MainActivityVM.kt
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/MainActivityVM.kt
@@ -3,7 +3,12 @@ package eu.darken.capod.wear.ui
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.capod.common.coroutine.DispatcherProvider
+import eu.darken.capod.common.permissions.Permission
import eu.darken.capod.common.uix.ViewModel2
+import eu.darken.capod.main.core.PermissionTool
+import eu.darken.capod.monitor.core.PodMonitor
+import eu.darken.capod.pods.core.PodDevice
+import kotlinx.coroutines.flow.combine
import javax.inject.Inject
@@ -11,4 +16,31 @@ import javax.inject.Inject
class MainActivityVM @Inject constructor(
handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
-) : ViewModel2(dispatcherProvider = dispatcherProvider)
\ No newline at end of file
+ private val podMonitor: PodMonitor,
+ private val permissionTool: PermissionTool,
+) : ViewModel2(dispatcherProvider = dispatcherProvider) {
+
+ sealed class State {
+ data class PermissionRequired(
+ val permissions: List,
+ ) : State()
+
+ data class Devices(
+ val devices: List
+ ) : State()
+ }
+
+ val state = combine(
+ podMonitor.devices,
+ permissionTool.missingPermissions,
+ ) { devices, missingPermissions ->
+ when {
+ missingPermissions.isNotEmpty() -> State.PermissionRequired(
+ permissions = missingPermissions.toList()
+ )
+ else -> State.Devices(
+ devices = devices
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/OverviewAdapter.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/OverviewAdapter.kt
new file mode 100644
index 00000000..4928f1d3
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/OverviewAdapter.kt
@@ -0,0 +1,47 @@
+package eu.darken.capod.wear.ui.overview
+
+import android.view.ViewGroup
+import androidx.annotation.LayoutRes
+import androidx.viewbinding.ViewBinding
+import eu.darken.capod.common.lists.BindableVH
+import eu.darken.capod.common.lists.differ.AsyncDiffer
+import eu.darken.capod.common.lists.differ.DifferItem
+import eu.darken.capod.common.lists.differ.HasAsyncDiffer
+import eu.darken.capod.common.lists.differ.setupDiffer
+import eu.darken.capod.common.lists.modular.ModularAdapter
+import eu.darken.capod.common.lists.modular.mods.DataBinderMod
+import eu.darken.capod.common.lists.modular.mods.TypedVHCreatorMod
+import eu.darken.capod.wear.ui.overview.cards.BluetoothDisabledVH
+import eu.darken.capod.wear.ui.overview.cards.MissingMainDeviceVH
+import eu.darken.capod.wear.ui.overview.cards.PermissionCardVH
+import eu.darken.capod.wear.ui.overview.cards.pods.DualPodsCardVH
+import eu.darken.capod.wear.ui.overview.cards.pods.SinglePodsCardVH
+import eu.darken.capod.wear.ui.overview.cards.pods.UnknownPodDeviceCardVH
+import javax.inject.Inject
+
+class OverviewAdapter @Inject constructor() :
+ ModularAdapter>(),
+ HasAsyncDiffer {
+
+ override val asyncDiffer: AsyncDiffer<*, Item> = setupDiffer()
+
+ init {
+ modules.add(DataBinderMod(data))
+ modules.add(TypedVHCreatorMod({ data[it] is PermissionCardVH.Item }) { PermissionCardVH(it) })
+ modules.add(TypedVHCreatorMod({ data[it] is DualPodsCardVH.Item }) { DualPodsCardVH(it) })
+ modules.add(TypedVHCreatorMod({ data[it] is SinglePodsCardVH.Item }) { SinglePodsCardVH(it) })
+ modules.add(TypedVHCreatorMod({ data[it] is MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) })
+ modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
+ modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
+ }
+
+ override fun getItemCount(): Int = data.size
+
+ abstract class BaseVH(
+ @LayoutRes layoutId: Int,
+ parent: ViewGroup
+ ) : ModularAdapter.VH(layoutId, parent), BindableVH
+
+ interface Item : DifferItem
+
+}
\ No newline at end of file
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/OverviewFragment.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/OverviewFragment.kt
new file mode 100644
index 00000000..f58c7a38
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/OverviewFragment.kt
@@ -0,0 +1,166 @@
+package eu.darken.capod.wear.ui.overview
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import android.text.SpannableStringBuilder
+import android.view.View
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.fragment.app.viewModels
+import dagger.hilt.android.AndroidEntryPoint
+import eu.darken.capod.BuildConfig
+import eu.darken.capod.R
+import eu.darken.capod.common.colorString
+import eu.darken.capod.common.debug.logging.log
+import eu.darken.capod.common.lists.differ.update
+import eu.darken.capod.common.lists.setupDefaults
+import eu.darken.capod.common.permissions.Permission
+import eu.darken.capod.common.uix.Fragment3
+import eu.darken.capod.common.upgrade.UpgradeRepo
+import eu.darken.capod.common.viewbinding.viewBinding
+import eu.darken.capod.databinding.MainFragmentBinding
+import javax.inject.Inject
+
+
+@AndroidEntryPoint
+class OverviewFragment : Fragment3(R.layout.main_fragment) {
+
+ override val vm: OverviewFragmentVM by viewModels()
+ override val ui: MainFragmentBinding by viewBinding()
+
+ @Inject
+ lateinit var adapter: OverviewAdapter
+
+ lateinit var permissionLauncher: ActivityResultLauncher
+ var awaitingPermission = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ awaitingPermission = savedInstanceState?.getBoolean("awaitingPermission") ?: false
+
+ permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ log { "Request for $id was granted=$granted" }
+ vm.onPermissionResult(granted)
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ ui.apply {
+ list.setupDefaults(adapter, dividers = false)
+ }
+
+ ui.toolbar.apply {
+// setOnMenuItemClickListener {
+// when (it.itemId) {
+// R.id.menu_item_settings -> {
+// vm.goToSettings()
+// true
+// }
+// R.id.menu_item_donate -> {
+// vm.onUpgrade()
+// true
+// }
+// R.id.menu_item_upgrade -> {
+// vm.onUpgrade()
+// true
+// }
+// else -> false
+// }
+// }
+ }
+
+ vm.listItems.observe2(ui) {
+ if (BuildConfig.DEBUG) toolbar.subtitle = "${it.size} items"
+ adapter.update(it)
+ }
+
+ vm.workerAutolaunch.observe2 {
+ // While UI is active, subscribe to the autolaunch routine
+ }
+
+ vm.requestPermissionEvent.observe2(ui) {
+ when (it) {
+ Permission.IGNORE_BATTERY_OPTIMIZATION -> {
+ awaitingPermission = true
+ startActivity(
+ Intent(
+ Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
+ Uri.parse("package:${requireContext().packageName}")
+ )
+ )
+ }
+ Permission.SYSTEM_ALERT_WINDOW -> {
+ awaitingPermission = true
+ startActivity(
+ Intent(
+ Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
+ Uri.parse("package:${requireContext().packageName}")
+ )
+ )
+ }
+ else -> {
+ permissionLauncher.launch(it.permissionId)
+ }
+ }
+ }
+
+ vm.upgradeState.observe2(ui) { info ->
+// val gplay = toolbar.menu.findItem(R.id.menu_item_upgrade)
+// val donate = toolbar.menu.findItem(R.id.menu_item_donate)
+// gplay.isVisible = false
+// donate.isVisible = false
+//
+// val baseTitle = when (info.type) {
+// UpgradeRepo.Type.GPLAY -> {
+// if (info.isPro) {
+// getString(R.string.app_name_pro)
+// } else {
+// gplay.isVisible = true
+// getString(R.string.app_name)
+// }
+// }
+// UpgradeRepo.Type.FOSS -> {
+// if (info.isPro) {
+// getString(R.string.app_name_foss)
+// } else {
+// donate.isVisible = true
+// getString(R.string.app_name)
+// }
+// }
+// }.split(" ".toRegex())
+// .dropLastWhile { it.isEmpty() }
+// .toTypedArray()
+//
+// toolbar.title = if (baseTitle.size == 2) {
+// val builder = SpannableStringBuilder(baseTitle[0] + " ")
+// val color = when (info.type) {
+// UpgradeRepo.Type.FOSS -> R.color.brand_secondary
+// else -> R.color.brand_tertiary
+// }
+// builder.append(colorString(requireContext(), color, baseTitle[1]))
+// } else {
+// getString(R.string.app_name)
+// }
+ }
+ vm.launchUpgradeFlow.observe2 {
+ it(requireActivity())
+ }
+ super.onViewCreated(view, savedInstanceState)
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ outState.putBoolean("awaitingPermission", awaitingPermission)
+ super.onSaveInstanceState(outState)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (awaitingPermission) {
+ awaitingPermission = false
+ log { "awaitingPermission=true" }
+ vm.onPermissionResult(true)
+ }
+ }
+}
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/OverviewFragmentVM.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/OverviewFragmentVM.kt
new file mode 100644
index 00000000..5918bf1c
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/OverviewFragmentVM.kt
@@ -0,0 +1,173 @@
+package eu.darken.capod.wear.ui.overview
+
+import android.app.Activity
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.SavedStateHandle
+import dagger.hilt.android.lifecycle.HiltViewModel
+import eu.darken.capod.common.bluetooth.BluetoothManager2
+import eu.darken.capod.common.coroutine.DispatcherProvider
+import eu.darken.capod.common.debug.autoreport.DebugSettings
+import eu.darken.capod.common.debug.logging.log
+import eu.darken.capod.common.flow.combine
+import eu.darken.capod.common.livedata.SingleLiveEvent
+import eu.darken.capod.common.navigation.navVia
+import eu.darken.capod.common.permissions.Permission
+import eu.darken.capod.common.uix.ViewModel3
+import eu.darken.capod.common.upgrade.UpgradeRepo
+import eu.darken.capod.main.core.GeneralSettings
+import eu.darken.capod.main.core.MonitorMode
+import eu.darken.capod.main.core.PermissionTool
+import eu.darken.capod.monitor.core.PodMonitor
+import eu.darken.capod.pods.core.DualPodDevice
+import eu.darken.capod.pods.core.PodDevice
+import eu.darken.capod.pods.core.SinglePodDevice
+import eu.darken.capod.wear.ui.overview.cards.BluetoothDisabledVH
+import eu.darken.capod.wear.ui.overview.cards.MissingMainDeviceVH
+import eu.darken.capod.wear.ui.overview.cards.PermissionCardVH
+import eu.darken.capod.wear.ui.overview.cards.pods.DualPodsCardVH
+import eu.darken.capod.wear.ui.overview.cards.pods.SinglePodsCardVH
+import eu.darken.capod.wear.ui.overview.cards.pods.UnknownPodDeviceCardVH
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.isActive
+import java.time.Instant
+import javax.inject.Inject
+
+@HiltViewModel
+class OverviewFragmentVM @Inject constructor(
+ @Suppress("UNUSED_PARAMETER") handle: SavedStateHandle,
+ dispatcherProvider: DispatcherProvider,
+// private val monitorControl: MonitorControl,
+ private val podMonitor: PodMonitor,
+ private val permissionTool: PermissionTool,
+ private val generalSettings: GeneralSettings,
+ debugSettings: DebugSettings,
+ private val upgradeRepo: UpgradeRepo,
+ private val bluetoothManager: BluetoothManager2,
+) : ViewModel3(dispatcherProvider = dispatcherProvider) {
+
+ val upgradeState = upgradeRepo.upgradeInfo.asLiveData2()
+ val launchUpgradeFlow = SingleLiveEvent<(Activity) -> Unit>()
+
+ private val updateTicker = channelFlow {
+ while (isActive) {
+ trySend(Unit)
+ delay(3000)
+ }
+ }
+
+ val workerAutolaunch: LiveData = permissionTool.missingPermissions
+ .onEach {
+ if (it.isNotEmpty()) {
+ log(TAG) { "Missing permissions: $it" }
+ return@onEach
+ }
+
+ val shouldStartMonitor = when (generalSettings.monitorMode.value) {
+ MonitorMode.MANUAL -> false
+ MonitorMode.AUTOMATIC -> bluetoothManager.connectedDevices().first().isNotEmpty()
+ MonitorMode.ALWAYS -> true
+ }
+ if (shouldStartMonitor) {
+ log(TAG) { "Starting monitor" }
+// monitorControl.startMonitor()
+ }
+ }
+ .map { }
+ .asLiveData2()
+
+ val requestPermissionEvent = SingleLiveEvent()
+
+ private val pods: Flow> = permissionTool.missingPermissions
+ .flatMapLatest { permissions ->
+ if (permissions.isNotEmpty()) {
+ return@flatMapLatest flowOf(emptyList())
+ }
+
+ generalSettings.showAll.flow.flatMapLatest { showAll ->
+ if (showAll) {
+ podMonitor.devices
+ } else {
+ podMonitor.mainDevice.map { mainDevice ->
+ mainDevice?.let { listOf(it) } ?: emptyList()
+ }
+ }
+ }
+ }
+ .catch { errorEvents.postValue(it) }
+
+ val listItems: LiveData> = combine(
+ updateTicker,
+ permissionTool.missingPermissions,
+ pods,
+ debugSettings.isDebugModeEnabled.flow,
+ generalSettings.showAll.flow,
+ bluetoothManager.isBluetoothEnabled,
+ podMonitor.mainDevice,
+ ) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled, mainPod ->
+ val items = mutableListOf()
+
+ permissions
+ .map { perm ->
+ PermissionCardVH.Item(
+ permission = perm,
+ onRequest = { requestPermissionEvent.postValue(it) },
+ )
+ }
+ .run { items.addAll(this) }
+
+ if (permissions.isEmpty()) {
+ if (!isBluetoothEnabled) {
+ items.add(0, BluetoothDisabledVH.Item)
+ } else if (mainPod == null) {
+ items.add(0, MissingMainDeviceVH.Item)
+ }
+ }
+
+ if (permissions.isEmpty() && isBluetoothEnabled) {
+ pods.map {
+ val now = Instant.now()
+ when (it) {
+ is DualPodDevice -> DualPodsCardVH.Item(
+ now = now,
+ device = it,
+ showDebug = isDebugMode,
+ isMainPod = it == mainPod,
+ )
+ is SinglePodDevice -> SinglePodsCardVH.Item(
+ now = now,
+ device = it,
+ showDebug = isDebugMode,
+ isMainPod = it == mainPod,
+ )
+ else -> UnknownPodDeviceCardVH.Item(
+ now = now,
+ device = it,
+ showDebug = isDebugMode,
+ isMainPod = it == mainPod,
+ )
+ }
+ }.run { items.addAll(this) }
+ }
+
+ items
+ }
+ .catch { errorEvents.postValue(it) }
+ .asLiveData2()
+
+ fun onPermissionResult(granted: Boolean) {
+ if (granted) permissionTool.recheck()
+ }
+
+ fun goToSettings() = launch {
+ OverviewFragmentDirections.actionOverviewFragmentToSettingsFragment().navVia(this@OverviewFragmentVM)
+ }
+
+ fun onUpgrade() = launch {
+ val call: (Activity) -> Unit = {
+ upgradeRepo.launchBillingFlow(it)
+ }
+ launchUpgradeFlow.postValue(call)
+ }
+
+}
\ No newline at end of file
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/BluetoothDisabledVH.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/BluetoothDisabledVH.kt
new file mode 100644
index 00000000..41b77519
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/BluetoothDisabledVH.kt
@@ -0,0 +1,33 @@
+package eu.darken.capod.wear.ui.overview.cards
+
+import android.view.ViewGroup
+import eu.darken.capod.R
+import eu.darken.capod.common.lists.binding
+import eu.darken.capod.common.lists.differ.DifferItem
+import eu.darken.capod.databinding.OverviewBluetoothDisabledItemBinding
+import eu.darken.capod.wear.ui.overview.OverviewAdapter
+
+class BluetoothDisabledVH(parent: ViewGroup) :
+ OverviewAdapter.BaseVH(
+ R.layout.overview_bluetooth_disabled_item,
+ parent
+ ) {
+
+ override val viewBinding = lazy {
+ OverviewBluetoothDisabledItemBinding.bind(itemView)
+ }
+
+ override val onBindData: OverviewBluetoothDisabledItemBinding.(
+ item: Item,
+ payloads: List
+ ) -> Unit = binding(payload = true) { item ->
+
+ }
+
+ object Item : OverviewAdapter.Item {
+ override val stableId: Long = Item::class.hashCode().toLong()
+
+ override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
+ get() = { old, new -> if (new::class.isInstance(old)) new else null }
+ }
+}
\ No newline at end of file
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/MissingMainDeviceVH.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/MissingMainDeviceVH.kt
new file mode 100644
index 00000000..71dddf65
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/MissingMainDeviceVH.kt
@@ -0,0 +1,33 @@
+package eu.darken.capod.wear.ui.overview.cards
+
+import android.view.ViewGroup
+import eu.darken.capod.R
+import eu.darken.capod.common.lists.binding
+import eu.darken.capod.common.lists.differ.DifferItem
+import eu.darken.capod.databinding.OverviewNomaindeviceItemBinding
+import eu.darken.capod.wear.ui.overview.OverviewAdapter
+
+class MissingMainDeviceVH(parent: ViewGroup) :
+ OverviewAdapter.BaseVH(
+ R.layout.overview_nomaindevice_item,
+ parent
+ ) {
+
+ override val viewBinding = lazy {
+ OverviewNomaindeviceItemBinding.bind(itemView)
+ }
+
+ override val onBindData: OverviewNomaindeviceItemBinding.(
+ item: Item,
+ payloads: List
+ ) -> Unit = binding(payload = true) { item ->
+
+ }
+
+ object Item : OverviewAdapter.Item {
+ override val stableId: Long = Item::class.hashCode().toLong()
+
+ override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
+ get() = { old, new -> if (new::class.isInstance(old)) new else null }
+ }
+}
\ No newline at end of file
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/PermissionCardVH.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/PermissionCardVH.kt
new file mode 100644
index 00000000..be2345c6
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/PermissionCardVH.kt
@@ -0,0 +1,48 @@
+package eu.darken.capod.wear.ui.overview.cards
+
+import android.text.Html
+import android.text.method.LinkMovementMethod
+import android.view.ViewGroup
+import eu.darken.capod.R
+import eu.darken.capod.common.PrivacyPolicy
+import eu.darken.capod.common.lists.binding
+import eu.darken.capod.common.lists.differ.DifferItem
+import eu.darken.capod.common.permissions.Permission
+import eu.darken.capod.databinding.OverviewPermissionItemBinding
+import eu.darken.capod.wear.ui.overview.OverviewAdapter
+
+class PermissionCardVH(parent: ViewGroup) :
+ OverviewAdapter.BaseVH(
+ R.layout.overview_permission_item,
+ parent
+ ) {
+
+ override val viewBinding = lazy {
+ OverviewPermissionItemBinding.bind(itemView)
+ }
+
+ override val onBindData: OverviewPermissionItemBinding.(
+ item: Item,
+ payloads: List
+ ) -> Unit = binding(payload = true) { item ->
+ permissionLabel.setText(item.permission.labelRes)
+ permissionDescription.setText(item.permission.descriptionRes)
+ grantAction.setOnClickListener { item.onRequest(item.permission) }
+ privacyPolicy.apply {
+ movementMethod = LinkMovementMethod.getInstance()
+ val ppText = getString(R.string.settings_privacy_policy_label)
+ val ppLink = PrivacyPolicy.URL
+ text = Html.fromHtml("$ppText", 0)
+ }
+ }
+
+ data class Item(
+ val permission: Permission,
+ val onRequest: (Permission) -> Unit
+ ) : OverviewAdapter.Item {
+ override val stableId: Long = permission.hashCode().toLong()
+
+ override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)
+ get() = { old, new -> if (new::class.isInstance(old)) new else null }
+ }
+}
\ No newline at end of file
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/DualPodsCardVH.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/DualPodsCardVH.kt
new file mode 100644
index 00000000..d2ee8ca8
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/DualPodsCardVH.kt
@@ -0,0 +1,166 @@
+package eu.darken.capod.wear.ui.overview.cards.pods
+
+import android.graphics.Typeface
+import android.view.ViewGroup
+import androidx.core.view.isGone
+import androidx.core.view.isInvisible
+import eu.darken.capod.R
+import eu.darken.capod.common.lists.binding
+import eu.darken.capod.databinding.OverviewPodsDualItemBinding
+import eu.darken.capod.pods.core.*
+import eu.darken.capod.pods.core.apple.DualAirPods
+import eu.darken.capod.pods.core.apple.DualAirPods.LidState
+import java.time.Duration
+import java.time.Instant
+
+class DualPodsCardVH(parent: ViewGroup) :
+ PodDeviceVH(
+ R.layout.overview_pods_dual_item,
+ parent
+ ) {
+
+ override val viewBinding = lazy { OverviewPodsDualItemBinding.bind(itemView) }
+
+ override val onBindData = binding(payload = true) { item: Item ->
+ val device = item.device
+ name.apply {
+ val sb = StringBuilder(device.getLabel(context))
+ if (device is HasPodStyle && item.showDebug) {
+ val style = device.podStyle
+ sb.append(" (${style.getColor(context)})")
+ }
+ text = sb
+
+ if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
+ else setTypeface(typeface, Typeface.NORMAL)
+
+ if (device is DualAirPods && item.showDebug) {
+ append(" [${device.primaryPod.name}]")
+ }
+ }
+ deviceIcon.setImageResource(device.iconRes)
+
+ lastSeen.text = context.getString(R.string.last_seen_x, device.lastSeenFormatted(item.now))
+ firstSeen.text = context.getString(R.string.first_seen_x, device.firstSeenFormatted(item.now))
+ firstSeen.isGone = Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() < 1
+
+ reception.text = item.getReceptionText()
+
+ // Pods battery state
+ device.apply {
+ podLeftBatteryIcon.setImageResource(getBatteryDrawable(batteryLeftPodPercent))
+ podLeftBatteryLabel.text = getBatteryLevelLeftPod(context)
+
+ podRightBatteryIcon.setImageResource(getBatteryDrawable(batteryRightPodPercent))
+ podRightBatteryLabel.text = getBatteryLevelRightPod(context)
+ }
+
+ // Pods charging state
+ device.apply {
+ if (this is HasChargeDetectionDual) {
+ podLeftChargingIcon.isInvisible = !isLeftPodCharging
+ podLeftChargingLabel.isInvisible = !isLeftPodCharging
+
+ podRightChargingIcon.isInvisible = !isRightPodCharging
+ podRightChargingLabel.isInvisible = !isRightPodCharging
+ } else {
+ podLeftChargingIcon.isGone = true
+ podLeftChargingLabel.isGone = true
+
+ podRightChargingIcon.isGone = true
+ podRightChargingLabel.isGone = true
+ }
+ }
+
+ // Microphone state
+ device.apply {
+ if (this is HasDualMicrophone) {
+ podLeftMicrophoneIcon.isInvisible = !isLeftPodMicrophone
+ podLeftMicrophoneLabel.isInvisible = !isLeftPodMicrophone
+
+ podRightMicrophoneIcon.isInvisible = !isRightPodMicrophone
+ podRightMicrophoneLabel.isInvisible = !isRightPodMicrophone
+ } else {
+ podLeftMicrophoneIcon.isGone = true
+ podLeftMicrophoneLabel.isGone = true
+
+ podRightMicrophoneIcon.isGone = true
+ podRightMicrophoneLabel.isGone = true
+ }
+ }
+
+ // Pods wear state
+ device.apply {
+ if (this is HasEarDetectionDual) {
+ podLeftWearIcon.isInvisible = !isLeftPodInEar
+ podLeftWearLabel.isInvisible = !isLeftPodInEar
+
+ podRightWearIcon.isInvisible = !isRightPodInEar
+ podRightWearLabel.isInvisible = !isRightPodInEar
+ } else {
+ podLeftWearIcon.isGone = true
+ podLeftWearLabel.isGone = true
+
+ podRightWearIcon.isGone = true
+ podRightWearLabel.isGone = true
+ }
+ }
+
+ // Case charge state
+ device.apply {
+ if (this is HasCase) {
+ podCaseBatteryIcon.isGone = false
+ podCaseBatteryIcon.setImageResource(getBatteryDrawable(batteryCasePercent))
+ podCaseBatteryLabel.text = getBatteryLevelCase(context)
+
+ podCaseChargingIcon.isInvisible = !isCaseCharging
+ podCaseChargingLabel.isInvisible = !isCaseCharging
+ } else {
+ podCaseBatteryIcon.isGone = true
+ podCaseBatteryLabel.isGone = true
+
+ podCaseChargingIcon.isGone = true
+ podCaseChargingLabel.isGone = true
+ }
+ }
+
+ // Case lid state
+ device.apply {
+ if (this is DualAirPods) {
+ podCaseLidLabel.text = when (caseLidState) {
+ LidState.OPEN -> context.getString(R.string.pods_case_status_open_label)
+ LidState.CLOSED -> context.getString(R.string.pods_case_status_closed_label)
+ else -> context.getString(R.string.pods_case_unknown_state)
+ }
+
+ val hideInfo = !listOf(LidState.OPEN, LidState.CLOSED).contains(caseLidState)
+ podCaseLidIcon.isInvisible = hideInfo
+ podCaseLidLabel.isInvisible = hideInfo
+ } else {
+ podCaseLidIcon.isGone = true
+ podCaseLidLabel.isGone = true
+ }
+ }
+
+ // Connection state
+ device.apply {
+ val sb = StringBuilder()
+ if (this is HasStateDetection) {
+ sb.append(state.getLabel(context))
+ }
+ if (item.showDebug) {
+ sb.append("\n\n").append("---Debug---")
+ sb.append("\n").append(rawDataHex)
+ }
+ status.text = sb
+ status.isGone = sb.isEmpty()
+ }
+ }
+
+ data class Item(
+ override val now: Instant,
+ override val device: DualPodDevice,
+ override val showDebug: Boolean,
+ override val isMainPod: Boolean,
+ ) : PodDeviceVH.Item
+}
\ No newline at end of file
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/PodDeviceVH.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/PodDeviceVH.kt
new file mode 100644
index 00000000..b5de2298
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/PodDeviceVH.kt
@@ -0,0 +1,42 @@
+package eu.darken.capod.wear.ui.overview.cards.pods
+
+import android.view.ViewGroup
+import androidx.annotation.LayoutRes
+import androidx.viewbinding.ViewBinding
+import eu.darken.capod.R
+import eu.darken.capod.common.lists.BindableVH
+import eu.darken.capod.common.lists.differ.DifferItem
+import eu.darken.capod.common.lists.modular.ModularAdapter
+import eu.darken.capod.wear.ui.overview.OverviewAdapter
+import eu.darken.capod.pods.core.PodDevice
+import eu.darken.capod.pods.core.getSignalQuality
+import java.time.Instant
+
+abstract class PodDeviceVH(
+ @LayoutRes layoutId: Int,
+ parent: ViewGroup
+) : ModularAdapter.VH(layoutId, parent), BindableVH {
+
+ fun Item.getReceptionText(): String = device.getSignalQuality(context)
+ .let { if (showDebug) "$it ${device.seenCounter}" else it }
+ .let { if (isMainPod) "$it\n(${getString(R.string.pods_yours)})" else it }
+
+ interface Item : OverviewAdapter.Item {
+
+ val now: Instant
+
+ val device: PodDevice
+
+ val showDebug: Boolean
+
+ val isMainPod: Boolean
+
+ override val stableId: Long get() = device.identifier.hashCode().toLong()
+
+ override val payloadProvider: ((DifferItem, DifferItem) -> DifferItem?)?
+ get() = { old, new ->
+ if (new::class.isInstance(old)) new else null
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/SinglePodsCardVH.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/SinglePodsCardVH.kt
new file mode 100644
index 00000000..b9bd2ec9
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/SinglePodsCardVH.kt
@@ -0,0 +1,81 @@
+package eu.darken.capod.wear.ui.overview.cards.pods
+
+import android.graphics.Typeface
+import android.view.ViewGroup
+import androidx.core.view.isGone
+import androidx.core.view.isInvisible
+import eu.darken.capod.R
+import eu.darken.capod.common.lists.binding
+import eu.darken.capod.databinding.OverviewPodsSingleItemBinding
+import eu.darken.capod.pods.core.*
+import java.time.Instant
+
+class SinglePodsCardVH(parent: ViewGroup) :
+ PodDeviceVH(
+ R.layout.overview_pods_single_item,
+ parent
+ ) {
+
+ override val viewBinding = lazy { OverviewPodsSingleItemBinding.bind(itemView) }
+
+ override val onBindData = binding(payload = true) { item: Item ->
+ val device = item.device
+
+ name.apply {
+ text = device.getLabel(context)
+ if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
+ else setTypeface(typeface, Typeface.NORMAL)
+ }
+
+ deviceIcon.setImageResource(device.iconRes)
+
+ lastSeen.text = device.lastSeenFormatted(item.now)
+
+ reception.text = item.getReceptionText()
+
+ // Battery level
+ device.apply {
+ batteryLabel.text = getBatteryLevelHeadset(context)
+ batteryIcon.setImageResource(getBatteryDrawable(batteryHeadsetPercent))
+ }
+
+ // Charge state
+ device.apply {
+ if (this is HasChargeDetection) {
+ chargingIcon.isInvisible = isHeadsetBeingCharged
+ chargingLabel.isInvisible = isHeadsetBeingCharged
+ } else {
+ chargingIcon.isGone = true
+ chargingLabel.isGone = true
+ }
+ }
+
+ // Has ear detection
+ device.apply {
+ if (this is HasEarDetection) {
+ wearIcon.isInvisible = isBeingWorn
+ wearLabel.isInvisible = isBeingWorn
+ } else {
+ wearIcon.isGone = true
+ wearLabel.isGone = true
+ }
+ }
+
+ status.apply {
+ val sb = StringBuilder()
+ if (item.showDebug) {
+ sb.append("--- Debug ---")
+ sb.append("\n").append(device.rawDataHex)
+ }
+ text = sb
+ isGone = !item.showDebug
+ }
+ }
+
+ data class Item(
+ override val now: Instant,
+ override val device: SinglePodDevice,
+ override val showDebug: Boolean,
+ override val isMainPod: Boolean,
+ ) : PodDeviceVH.Item
+}
\ No newline at end of file
diff --git a/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt
new file mode 100644
index 00000000..7cb3f64e
--- /dev/null
+++ b/app-wear/src/main/java/eu/darken/capod/wear/ui/overview/cards/pods/UnknownPodDeviceCardVH.kt
@@ -0,0 +1,48 @@
+package eu.darken.capod.wear.ui.overview.cards.pods
+
+import android.graphics.Typeface
+import android.view.ViewGroup
+import eu.darken.capod.R
+import eu.darken.capod.common.lists.binding
+import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding
+import eu.darken.capod.pods.core.PodDevice
+import eu.darken.capod.pods.core.apple.ApplePods
+import eu.darken.capod.pods.core.lastSeenFormatted
+import java.time.Instant
+
+class UnknownPodDeviceCardVH(parent: ViewGroup) :
+ PodDeviceVH(
+ R.layout.overview_pods_unknown_item,
+ parent
+ ) {
+
+ override val viewBinding = lazy {
+ OverviewPodsUnknownItemBinding.bind(itemView)
+ }
+
+ override val onBindData = binding(payload = true) { item ->
+ val device = item.device
+ name.apply {
+ text = device.getLabel(context)
+ if (item.isMainPod) setTypeface(typeface, Typeface.BOLD)
+ else setTypeface(typeface, Typeface.NORMAL)
+ }
+
+ lastSeen.text = device.lastSeenFormatted(item.now)
+ reception.text = item.getReceptionText()
+
+ details.text = when (item.device) {
+ is ApplePods -> getString(R.string.pods_unknown_contact_dev)
+ else -> getString(R.string.pods_unknown_label)
+ }
+
+ rawdata.text = device.rawDataHex.joinToString("\n")
+ }
+
+ data class Item(
+ override val now: Instant,
+ override val device: PodDevice,
+ override val showDebug: Boolean = false,
+ override val isMainPod: Boolean = false,
+ ) : PodDeviceVH.Item
+}
\ No newline at end of file
diff --git a/app-wear/src/main/res/layout/activity_main.xml b/app-wear/src/main/res/layout/activity_main.xml
deleted file mode 100644
index d10d150b..00000000
--- a/app-wear/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app-wear/src/main/res/layout/main_activity.xml b/app-wear/src/main/res/layout/main_activity.xml
new file mode 100644
index 00000000..68418b8c
--- /dev/null
+++ b/app-wear/src/main/res/layout/main_activity.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-wear/src/main/res/navigation/nav_graph.xml b/app-wear/src/main/res/navigation/nav_graph.xml
new file mode 100644
index 00000000..7a3fe0f3
--- /dev/null
+++ b/app-wear/src/main/res/navigation/nav_graph.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app-wear/src/main/res/values-night/themes.xml b/app-wear/src/main/res/values-night/themes.xml
new file mode 100644
index 00000000..d69a4b5b
--- /dev/null
+++ b/app-wear/src/main/res/values-night/themes.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app-wear/src/main/res/values/colors.xml b/app-wear/src/main/res/values/colors.xml
new file mode 100644
index 00000000..77c8e5d2
--- /dev/null
+++ b/app-wear/src/main/res/values/colors.xml
@@ -0,0 +1,63 @@
+
+
+ #3F7AFF
+ #FFFFFF
+ #DAE2FF
+ #00174B
+ #715C00
+ #FFFFFF
+ #FFE16C
+ #231B00
+ #006D39
+ #FFFFFF
+ #5CFFA0
+ #00210D
+ #BA1B1B
+ #FFDAD4
+ #FFFFFF
+ #410001
+ #FEFBFF
+ #1B1B1F
+ #FEFBFF
+ #1B1B1F
+ #E2E2EC
+ #44464E
+ #75767F
+ #F2F0F5
+ #303033
+ #B1C5FF
+
+ #3F7AFF
+ #002A78
+ #003EA7
+ #DAE2FF
+ #E9C426
+ #3B2F00
+ #564500
+ #FFE16C
+ #33E286
+ #00391B
+ #005229
+ #5CFFA0
+ #FFB4A9
+ #930006
+ #680003
+ #FFDAD4
+ #1B1B1F
+ #E3E1E6
+ #1B1B1F
+ #E3E1E6
+ #44464E
+ #C6C6D0
+ #8F909A
+ #1B1B1F
+ #E3E1E6
+ #0054D9
+
+ #3F7AFF
+ #BA1B1B
+
+ #3f7aff
+ #ffd83c
+ #41eb8e
+
\ No newline at end of file
diff --git a/app-wear/src/main/res/values/themes.xml b/app-wear/src/main/res/values/themes.xml
new file mode 100644
index 00000000..b9adb822
--- /dev/null
+++ b/app-wear/src/main/res/values/themes.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file