Wear UI wip

This commit is contained in:
darken
2022-09-14 18:00:18 +02:00
committed by Matthias Urhahn
parent eb4d883194
commit 2eb3f70e0c
21 changed files with 1156 additions and 52 deletions
+17 -19
View File
@@ -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")
}
+21 -1
View File
@@ -3,6 +3,26 @@
package="eu.darken.capod">
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-feature android:name="android.hardware.type.watch" />
@@ -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">
<uses-library
android:name="com.google.android.wearable"
@@ -31,7 +52,6 @@
</intent-filter>
</activity>
<service
android:name=".wear.core.CAPodTileService"
android:description="@string/wear_service_description"
@@ -0,0 +1,42 @@
package eu.darken.capod
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.getkeepsafe.relinker.ReLinker
import dagger.hilt.android.HiltAndroidApp
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.autoreport.AutoReporting
import eu.darken.capod.common.debug.logging.*
import kotlinx.coroutines.CoroutineScope
import javax.inject.Inject
@HiltAndroidApp
open class App : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var autoReporting: AutoReporting
@Inject @AppScope lateinit var appScope: CoroutineScope
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) Logging.install(LogCatLogger())
ReLinker
.log { message -> 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")
}
}
@@ -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)
}
}
}
@@ -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)
private val podMonitor: PodMonitor,
private val permissionTool: PermissionTool,
) : ViewModel2(dispatcherProvider = dispatcherProvider) {
sealed class State {
data class PermissionRequired(
val permissions: List<Permission>,
) : State()
data class Devices(
val devices: List<PodDevice>
) : State()
}
val state = combine(
podMonitor.devices,
permissionTool.missingPermissions,
) { devices, missingPermissions ->
when {
missingPermissions.isNotEmpty() -> State.PermissionRequired(
permissions = missingPermissions.toList()
)
else -> State.Devices(
devices = devices
)
}
}
}
@@ -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<OverviewAdapter.BaseVH<OverviewAdapter.Item, ViewBinding>>(),
HasAsyncDiffer<OverviewAdapter.Item> {
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<D : Item, B : ViewBinding>(
@LayoutRes layoutId: Int,
parent: ViewGroup
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B>
interface Item : DifferItem
}
@@ -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<String>
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)
}
}
}
@@ -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<Unit> {
while (isActive) {
trySend(Unit)
delay(3000)
}
}
val workerAutolaunch: LiveData<Unit> = 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<Permission>()
private val pods: Flow<List<PodDevice>> = 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<List<OverviewAdapter.Item>> = combine(
updateTicker,
permissionTool.missingPermissions,
pods,
debugSettings.isDebugModeEnabled.flow,
generalSettings.showAll.flow,
bluetoothManager.isBluetoothEnabled,
podMonitor.mainDevice,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled, mainPod ->
val items = mutableListOf<OverviewAdapter.Item>()
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)
}
}
@@ -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<BluetoothDisabledVH.Item, OverviewBluetoothDisabledItemBinding>(
R.layout.overview_bluetooth_disabled_item,
parent
) {
override val viewBinding = lazy {
OverviewBluetoothDisabledItemBinding.bind(itemView)
}
override val onBindData: OverviewBluetoothDisabledItemBinding.(
item: Item,
payloads: List<Any>
) -> 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 }
}
}
@@ -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<MissingMainDeviceVH.Item, OverviewNomaindeviceItemBinding>(
R.layout.overview_nomaindevice_item,
parent
) {
override val viewBinding = lazy {
OverviewNomaindeviceItemBinding.bind(itemView)
}
override val onBindData: OverviewNomaindeviceItemBinding.(
item: Item,
payloads: List<Any>
) -> 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 }
}
}
@@ -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<PermissionCardVH.Item, OverviewPermissionItemBinding>(
R.layout.overview_permission_item,
parent
) {
override val viewBinding = lazy {
OverviewPermissionItemBinding.bind(itemView)
}
override val onBindData: OverviewPermissionItemBinding.(
item: Item,
payloads: List<Any>
) -> 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("<html><a href=\"$ppLink\">$ppText</a></html>", 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 }
}
}
@@ -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<DualPodsCardVH.Item, OverviewPodsDualItemBinding>(
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
}
@@ -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<D : PodDeviceVH.Item, B : ViewBinding>(
@LayoutRes layoutId: Int,
parent: ViewGroup
) : ModularAdapter.VH(layoutId, parent), BindableVH<D, B> {
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
}
}
}
@@ -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<SinglePodsCardVH.Item, OverviewPodsSingleItemBinding>(
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
}
@@ -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<UnknownPodDeviceCardVH.Item, OverviewPodsUnknownItemBinding>(
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
}
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.wear.widget.BoxInsetLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".wear.ui.MainActivity"
tools:deviceIds="wear">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="@dimen/inner_frame_layout_padding"
app:layout_boxedEdges="all">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/text"
style="@style/TextAppearance.Material3.BodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name" />
</FrameLayout>
</androidx.wear.widget.BoxInsetLayout>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".wear.ui.MainActivity">
<fragment
android:id="@+id/nav_host"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="0dp"
android:layout_height="0dp"
app:defaultNavHost="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navGraph="@navigation/nav_graph" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/main"
app:startDestination="@id/overviewFragment">
<fragment
android:id="@+id/overviewFragment"
android:name="eu.darken.capod.wear.ui.overview.OverviewFragment"
tools:layout="@layout/main_fragment">
<action
android:id="@+id/action_overviewFragment_to_settingsFragment"
app:destination="@id/settingsFragment" />
</fragment>
<fragment
android:id="@+id/settingsFragment"
android:name="eu.darken.capod.main.ui.settings.SettingsFragment"
android:label="SettingsFragment"
tools:layout="@layout/settings_fragment" />
</navigation>
@@ -0,0 +1,32 @@
<resources>
<style name="AppTheme" parent="Theme.Material3.Dark.NoActionBar">
<item name="colorPrimary">@color/md_theme_dark_primary</item>
<item name="colorOnPrimary">@color/md_theme_dark_onPrimary</item>
<item name="colorPrimaryContainer">@color/md_theme_dark_primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/md_theme_dark_onPrimaryContainer</item>
<item name="colorSecondary">@color/md_theme_dark_secondary</item>
<item name="colorOnSecondary">@color/md_theme_dark_onSecondary</item>
<item name="colorSecondaryContainer">@color/md_theme_dark_secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/md_theme_dark_onSecondaryContainer</item>
<item name="colorTertiary">@color/md_theme_dark_tertiary</item>
<item name="colorOnTertiary">@color/md_theme_dark_onTertiary</item>
<item name="colorTertiaryContainer">@color/md_theme_dark_tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/md_theme_dark_onTertiaryContainer</item>
<item name="colorError">@color/md_theme_dark_error</item>
<item name="colorErrorContainer">@color/md_theme_dark_errorContainer</item>
<item name="colorOnError">@color/md_theme_dark_onError</item>
<item name="colorOnErrorContainer">@color/md_theme_dark_onErrorContainer</item>
<item name="android:colorBackground">@color/md_theme_dark_background</item>
<item name="colorOnBackground">@color/md_theme_dark_onBackground</item>
<item name="colorSurface">@color/md_theme_dark_surface</item>
<item name="colorOnSurface">@color/md_theme_dark_onSurface</item>
<item name="colorSurfaceVariant">@color/md_theme_dark_surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/md_theme_dark_onSurfaceVariant</item>
<item name="colorOutline">@color/md_theme_dark_outline</item>
<item name="colorOnSurfaceInverse">@color/md_theme_dark_inverseOnSurface</item>
<item name="colorSurfaceInverse">@color/md_theme_dark_inverseSurface</item>
<item name="colorPrimaryInverse">@color/md_theme_dark_primaryInverse</item>
</style>
</resources>
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="md_theme_light_primary">#3F7AFF</color>
<color name="md_theme_light_onPrimary">#FFFFFF</color>
<color name="md_theme_light_primaryContainer">#DAE2FF</color>
<color name="md_theme_light_onPrimaryContainer">#00174B</color>
<color name="md_theme_light_secondary">#715C00</color>
<color name="md_theme_light_onSecondary">#FFFFFF</color>
<color name="md_theme_light_secondaryContainer">#FFE16C</color>
<color name="md_theme_light_onSecondaryContainer">#231B00</color>
<color name="md_theme_light_tertiary">#006D39</color>
<color name="md_theme_light_onTertiary">#FFFFFF</color>
<color name="md_theme_light_tertiaryContainer">#5CFFA0</color>
<color name="md_theme_light_onTertiaryContainer">#00210D</color>
<color name="md_theme_light_error">#BA1B1B</color>
<color name="md_theme_light_errorContainer">#FFDAD4</color>
<color name="md_theme_light_onError">#FFFFFF</color>
<color name="md_theme_light_onErrorContainer">#410001</color>
<color name="md_theme_light_background">#FEFBFF</color>
<color name="md_theme_light_onBackground">#1B1B1F</color>
<color name="md_theme_light_surface">#FEFBFF</color>
<color name="md_theme_light_onSurface">#1B1B1F</color>
<color name="md_theme_light_surfaceVariant">#E2E2EC</color>
<color name="md_theme_light_onSurfaceVariant">#44464E</color>
<color name="md_theme_light_outline">#75767F</color>
<color name="md_theme_light_inverseOnSurface">#F2F0F5</color>
<color name="md_theme_light_inverseSurface">#303033</color>
<color name="md_theme_light_primaryInverse">#B1C5FF</color>
<color name="md_theme_dark_primary">#3F7AFF</color>
<color name="md_theme_dark_onPrimary">#002A78</color>
<color name="md_theme_dark_primaryContainer">#003EA7</color>
<color name="md_theme_dark_onPrimaryContainer">#DAE2FF</color>
<color name="md_theme_dark_secondary">#E9C426</color>
<color name="md_theme_dark_onSecondary">#3B2F00</color>
<color name="md_theme_dark_secondaryContainer">#564500</color>
<color name="md_theme_dark_onSecondaryContainer">#FFE16C</color>
<color name="md_theme_dark_tertiary">#33E286</color>
<color name="md_theme_dark_onTertiary">#00391B</color>
<color name="md_theme_dark_tertiaryContainer">#005229</color>
<color name="md_theme_dark_onTertiaryContainer">#5CFFA0</color>
<color name="md_theme_dark_error">#FFB4A9</color>
<color name="md_theme_dark_errorContainer">#930006</color>
<color name="md_theme_dark_onError">#680003</color>
<color name="md_theme_dark_onErrorContainer">#FFDAD4</color>
<color name="md_theme_dark_background">#1B1B1F</color>
<color name="md_theme_dark_onBackground">#E3E1E6</color>
<color name="md_theme_dark_surface">#1B1B1F</color>
<color name="md_theme_dark_onSurface">#E3E1E6</color>
<color name="md_theme_dark_surfaceVariant">#44464E</color>
<color name="md_theme_dark_onSurfaceVariant">#C6C6D0</color>
<color name="md_theme_dark_outline">#8F909A</color>
<color name="md_theme_dark_inverseOnSurface">#1B1B1F</color>
<color name="md_theme_dark_inverseSurface">#E3E1E6</color>
<color name="md_theme_dark_primaryInverse">#0054D9</color>
<color name="seed">#3F7AFF</color>
<color name="error">#BA1B1B</color>
<color name="brand_primary">#3f7aff</color>
<color name="brand_secondary">#ffd83c</color>
<color name="brand_tertiary">#41eb8e</color>
</resources>
+48
View File
@@ -0,0 +1,48 @@
<resources>
<style name="AppTheme" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/md_theme_light_primary</item>
<item name="colorOnPrimary">@color/md_theme_light_onPrimary</item>
<item name="colorPrimaryContainer">@color/md_theme_light_primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/md_theme_light_onPrimaryContainer</item>
<item name="colorSecondary">@color/md_theme_light_secondary</item>
<item name="colorOnSecondary">@color/md_theme_light_onSecondary</item>
<item name="colorSecondaryContainer">@color/md_theme_light_secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/md_theme_light_onSecondaryContainer</item>
<item name="colorTertiary">@color/md_theme_light_tertiary</item>
<item name="colorOnTertiary">@color/md_theme_light_onTertiary</item>
<item name="colorTertiaryContainer">@color/md_theme_light_tertiaryContainer</item>
<item name="colorOnTertiaryContainer">@color/md_theme_light_onTertiaryContainer</item>
<item name="colorError">@color/md_theme_light_error</item>
<item name="colorErrorContainer">@color/md_theme_light_errorContainer</item>
<item name="colorOnError">@color/md_theme_light_onError</item>
<item name="colorOnErrorContainer">@color/md_theme_light_onErrorContainer</item>
<item name="android:colorBackground">@color/md_theme_light_background</item>
<item name="colorOnBackground">@color/md_theme_light_onBackground</item>
<item name="colorSurface">@color/md_theme_light_surface</item>
<item name="colorOnSurface">@color/md_theme_light_onSurface</item>
<item name="colorSurfaceVariant">@color/md_theme_light_surfaceVariant</item>
<item name="colorOnSurfaceVariant">@color/md_theme_light_onSurfaceVariant</item>
<item name="colorOutline">@color/md_theme_light_outline</item>
<item name="colorOnSurfaceInverse">@color/md_theme_light_inverseOnSurface</item>
<item name="colorSurfaceInverse">@color/md_theme_light_inverseSurface</item>
<item name="colorPrimaryInverse">@color/md_theme_light_primaryInverse</item>
</style>
<style name="AppThemeFloating" parent="AppTheme">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">true</item>
</style>
<style name="AppThemeSplash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">#3f7aff</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_screen</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
</style>
</resources>