Skip to content

Commit

Permalink
Add battery level alerts logic and UI to config a threshold
Browse files Browse the repository at this point in the history
(worker and notification still missing)
  • Loading branch information
d4rken committed Sep 10, 2024
1 parent 9d0a721 commit 243a55f
Show file tree
Hide file tree
Showing 33 changed files with 596 additions and 32 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package eu.darken.octi.common

import android.graphics.Typeface
import android.widget.TextView

var TextView.isBold: Boolean
get() = typeface.isBold
set(value) {
setTypeface(null, if (value) Typeface.BOLD else Typeface.NORMAL)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package eu.darken.octi.common.flow

import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.transform


fun <T> Flow<T>.throttleLatest(delayMillis: Long): Flow<T> = this
.conflate()
.transform {
emit(it)
delay(delayMillis)
}
22 changes: 16 additions & 6 deletions app/src/main/java/eu/darken/octi/main/ui/dashboard/DashboardVM.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ import eu.darken.octi.modules.clipboard.ClipboardInfo
import eu.darken.octi.modules.clipboard.ClipboardVH
import eu.darken.octi.modules.meta.core.MetaInfo
import eu.darken.octi.modules.power.core.PowerInfo
import eu.darken.octi.modules.power.core.alerts.BatteryLowAlert
import eu.darken.octi.modules.power.core.alerts.PowerAlert
import eu.darken.octi.modules.power.core.alerts.PowerAlertManager
import eu.darken.octi.modules.power.ui.dashboard.DevicePowerVH
import eu.darken.octi.modules.wifi.core.WifiInfo
import eu.darken.octi.modules.wifi.ui.dashboard.DeviceWifiVH
Expand All @@ -46,7 +49,6 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onStart
Expand Down Expand Up @@ -74,6 +76,7 @@ class DashboardVM @Inject constructor(
private val webpageTool: WebpageTool,
private val clipboardHandler: ClipboardHandler,
private val updateService: UpdateService,
private val alertManager: PowerAlertManager,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {

init {
Expand Down Expand Up @@ -215,21 +218,22 @@ class DashboardVM @Inject constructor(
moduleManager.byDevice,
permissionTool.missingPermissions,
syncManager.connectors,
) { now, byDevice, missingPermissions, connectors ->
alertManager.alerts,
) { now, byDevice, missingPermissions, connectors, alerts ->
byDevice.devices
.mapNotNull { (deviceId, moduleDatas) ->
val metaModule =
moduleDatas.firstOrNull { it.data is MetaInfo } as? ModuleData<MetaInfo>
val metaModule = moduleDatas.firstOrNull { it.data is MetaInfo } as? ModuleData<MetaInfo>
if (metaModule == null) {
log(TAG, WARN) { "Missing meta module for $deviceId" }
return@mapNotNull null
}

val powerAlerts = alerts.filter { it.deviceId == deviceId }
val moduleItems = (moduleDatas.toList() - metaModule)
.sortedBy { it.orderPrio }
.mapNotNull { moduleData ->
when (moduleData.data) {
is PowerInfo -> (moduleData as ModuleData<PowerInfo>).createVHItem()
is PowerInfo -> (moduleData as ModuleData<PowerInfo>).createVHItem(powerAlerts)
is WifiInfo -> (moduleData as ModuleData<WifiInfo>).createVHItem(missingPermissions)
is AppsInfo -> (moduleData as ModuleData<AppsInfo>).createVHItem()
is ClipboardInfo -> (moduleData as ModuleData<ClipboardInfo>).createVHItem()
Expand All @@ -253,8 +257,14 @@ class DashboardVM @Inject constructor(
private val ModuleData<out Any>.orderPrio: Int
get() = INFO_ORDER.indexOfFirst { it.isInstance(this.data) }

private fun ModuleData<PowerInfo>.createVHItem() = DevicePowerVH.Item(
private fun ModuleData<PowerInfo>.createVHItem(
powerAlerts: Collection<PowerAlert>
): DevicePowerVH.Item = DevicePowerVH.Item(
data = this,
batteryLowAlert = powerAlerts.filterIsInstance<BatteryLowAlert>().firstOrNull(),
onSettingsAction = {
DashboardFragmentDirections.actionDashFragmentToPowerAlertsFragment(deviceId).navigate()
}.takeIf { deviceId != syncSettings.deviceId },
)

private fun ModuleData<WifiInfo>.createVHItem(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package eu.darken.octi.modules.power.ui

import androidx.annotation.DrawableRes
import eu.darken.octi.R
import eu.darken.octi.modules.power.R
import eu.darken.octi.modules.power.core.PowerInfo

@get:DrawableRes
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package eu.darken.octi.modules.power.ui.alerts

sealed interface PowerAlertsAction
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package eu.darken.octi.modules.power.ui.alerts

import android.os.Bundle
import android.view.View
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.setupWithNavController
import com.google.android.material.slider.Slider
import dagger.hilt.android.AndroidEntryPoint
import eu.darken.octi.R
import eu.darken.octi.common.isBold
import eu.darken.octi.common.observe2
import eu.darken.octi.common.uix.Fragment3
import eu.darken.octi.common.viewbinding.viewBinding
import eu.darken.octi.databinding.ModulePowerAlertsFragmentBinding


@AndroidEntryPoint
class PowerAlertsFragment : Fragment3(R.layout.module_power_alerts_fragment) {

override val vm: PowerAlertsVM by viewModels()
override val ui: ModulePowerAlertsFragmentBinding by viewBinding()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
ui.toolbar.apply {
setupWithNavController(findNavController())
setOnMenuItemClickListener {
when (it.itemId) {
else -> super.onOptionsItemSelected(it)
}
}
}

ui.lowbatteryThresholdSlider.apply {
addOnChangeListener { _, value, _ ->
ui.lowbatteryThresholdSliderCaption.text = when (value) {
0f -> getString(R.string.module_power_alerts_lowbattery_disabled_caption)
else -> getString(
R.string.module_power_alerts_lowbattery_slider_value_caption,
"${(value * 100).toInt()}%"
)
}
}
addOnSliderTouchListener(object : Slider.OnSliderTouchListener {
override fun onStartTrackingTouch(slider: Slider) {}

override fun onStopTrackingTouch(slider: Slider) {
vm.setBatteryLowAlert(slider.value)
}
})
stepSize = 0.05f
valueFrom = 0f
valueTo = 0.95f
}

vm.state.observe2(this@PowerAlertsFragment, ui) { state ->
ui.toolbar.subtitle = getString(R.string.device_x_label, state.deviceLabel)
lowbatteryThresholdSlider.value = state.batteryLowAlert?.threshold ?: 0f
lowbatteryTitle.isBold = state.batteryLowAlert != null
}

vm.events.observe2 { event ->

}

super.onViewCreated(view, savedInstanceState)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package eu.darken.octi.modules.power.ui.alerts

import android.annotation.SuppressLint
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import eu.darken.octi.common.coroutine.DispatcherProvider
import eu.darken.octi.common.debug.logging.Logging.Priority.ERROR
import eu.darken.octi.common.debug.logging.log
import eu.darken.octi.common.debug.logging.logTag
import eu.darken.octi.common.livedata.SingleLiveEvent
import eu.darken.octi.common.navigation.navArgs
import eu.darken.octi.common.uix.ViewModel3
import eu.darken.octi.modules.meta.core.MetaRepo
import eu.darken.octi.modules.power.core.alerts.BatteryLowAlert
import eu.darken.octi.modules.power.core.alerts.PowerAlertManager
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import java.util.Locale
import javax.inject.Inject

@HiltViewModel
@SuppressLint("StaticFieldLeak")
class PowerAlertsVM @Inject constructor(
handle: SavedStateHandle,
dispatcherProvider: DispatcherProvider,
metaRepo: MetaRepo,
private val alertsManager: PowerAlertManager,
) : ViewModel3(dispatcherProvider = dispatcherProvider) {

private val navArgs: PowerAlertsFragmentArgs by handle.navArgs()

val events = SingleLiveEvent<PowerAlertsAction>()

init {
launch { alertsManager.dismissBatteryLowAlert(navArgs.deviceId) }
}

data class State(
val deviceLabel: String = "",
val batteryLowAlert: BatteryLowAlert? = null,
)

val state = combine(
alertsManager.alerts.map { alerts -> alerts.filter { it.deviceId == navArgs.deviceId } },
metaRepo.state,
) { alerts, metaState ->
val metaData = metaState.all.firstOrNull { it.deviceId == navArgs.deviceId }

if (metaData == null) {
log(TAG, ERROR) { "No meta data found for ${navArgs.deviceId}" }
popNavStack()
return@combine State()
}

State(
deviceLabel = metaData.data.deviceLabel ?: metaData.data.deviceName,
batteryLowAlert = alerts.find { it is BatteryLowAlert } as BatteryLowAlert?
)
}.asLiveData2()

fun setBatteryLowAlert(threshold: Float) = launch {
log(TAG) { "setBatteryLowAlert($threshold)" }
val cleanThreshold = String.format(Locale.ROOT, "%.2f", threshold.coerceIn(0f, 95f)).toFloat()
alertsManager.setBatteryLowAlert(navArgs.deviceId, cleanThreshold.takeIf { it > 0f })
}

companion object {
private val TAG = logTag("Module", "Power", "Alerts", "Fragment", "VM")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ package eu.darken.octi.modules.power.ui.dashboard
import android.content.res.ColorStateList
import android.text.format.DateUtils
import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.widget.ImageViewCompat
import eu.darken.octi.R
import eu.darken.octi.common.getColorForAttr
import eu.darken.octi.common.isBold
import eu.darken.octi.databinding.DashboardDevicePowerItemBinding
import eu.darken.octi.main.ui.dashboard.items.perdevice.PerDeviceModuleAdapter
import eu.darken.octi.module.core.ModuleData
import eu.darken.octi.modules.power.core.PowerInfo
import eu.darken.octi.modules.power.core.PowerInfo.ChargeIO
import eu.darken.octi.modules.power.core.PowerInfo.Status
import eu.darken.octi.modules.power.core.alerts.BatteryLowAlert
import eu.darken.octi.modules.power.ui.batteryIconRes
import java.time.Duration
import java.time.Instant
Expand Down Expand Up @@ -52,24 +55,30 @@ class DevicePowerVH(parent: ViewGroup) :
Status.FULL -> {
getString(R.string.module_power_battery_status_full)
}

Status.CHARGING -> when (powerInfo.chargeIO.speed) {
ChargeIO.Speed.SLOW -> getString(R.string.module_power_battery_status_charging_slow)
ChargeIO.Speed.FAST -> getString(R.string.module_power_battery_status_charging_fast)
else -> getString(R.string.module_power_battery_status_charging)
}

Status.DISCHARGING -> when (powerInfo.chargeIO.speed) {
ChargeIO.Speed.SLOW -> getString(R.string.module_power_battery_status_discharging_slow)
ChargeIO.Speed.FAST -> getString(R.string.module_power_battery_status_discharging_fast)
else -> getString(R.string.module_power_battery_status_discharging)
}

else -> getString(R.string.module_power_battery_status_unknown)
}
text = "$percentTxt% • $stateTxt"

if (powerInfo.battery.percent < 0.1f && !powerInfo.isCharging) {
val lowAlert = item.batteryLowAlert
if (lowAlert?.triggeredAt != null && lowAlert.dismissedAt == null) {
setTextColor(context.getColor(R.color.error))
isBold = true
} else {
setTextColor(context.getColorForAttr(R.attr.colorOnSurface))
isBold = false
}
}

Expand All @@ -81,6 +90,7 @@ class DevicePowerVH(parent: ViewGroup) :
DateUtils.getRelativeTimeSpanString(powerInfo.chargeIO.fullSince!!.toEpochMilli())
)
}

powerInfo.status == Status.CHARGING
&& powerInfo.chargeIO.fullAt != null
&& Duration.between(Instant.now(), powerInfo.chargeIO.fullAt).isNegative -> {
Expand All @@ -89,6 +99,7 @@ class DevicePowerVH(parent: ViewGroup) :
DateUtils.getRelativeTimeSpanString(powerInfo.chargeIO.fullAt!!.toEpochMilli())
)
}

powerInfo.status == Status.CHARGING && powerInfo.chargeIO.fullAt != null -> {
getString(
R.string.module_power_battery_full_in_x,
Expand All @@ -99,6 +110,7 @@ class DevicePowerVH(parent: ViewGroup) :
)
)
}

powerInfo.status == Status.DISCHARGING && powerInfo.chargeIO.emptyAt != null -> {
getString(
R.string.module_power_battery_empty_in_x,
Expand All @@ -109,6 +121,7 @@ class DevicePowerVH(parent: ViewGroup) :
)
)
}

else -> getString(R.string.module_power_battery_estimation_na)
}

Expand All @@ -120,10 +133,18 @@ class DevicePowerVH(parent: ViewGroup) :
estimationText
}
}

alertsIcon.isGone = item.batteryLowAlert == null
settingsAction.apply {
setOnClickListener { item.onSettingsAction?.invoke() }
isGone = item.onSettingsAction == null
}
}

data class Item(
val data: ModuleData<PowerInfo>,
val batteryLowAlert: BatteryLowAlert?,
val onSettingsAction: (() -> Unit)?,
) : PerDeviceModuleAdapter.Item {
override val stableId: Long = data.moduleId.hashCode().toLong()
}
Expand Down
15 changes: 10 additions & 5 deletions app/src/main/res/layout/dashboard_device_apps_item.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:paddingStart="0dp">
android:paddingStart="0dp"
android:paddingEnd="16dp">

<ImageView
android:id="@+id/tap_indicator"
Expand All @@ -18,9 +19,9 @@
app:layout_constraintTop_toTopOf="parent" />

<ImageView
android:id="@+id/wifi_icon"
android:layout_width="20dp"
android:layout_height="20dp"
android:id="@+id/apps_icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="16dp"
android:src="@drawable/ic_baseline_apps_24"
app:layout_constraintBottom_toBottomOf="@id/apps_secondary"
Expand All @@ -34,10 +35,13 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:singleLine="true"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintBottom_toTopOf="@id/apps_secondary"
app:layout_constraintEnd_toStartOf="@id/install_action"
app:layout_constraintStart_toEndOf="@id/wifi_icon"
app:layout_constraintStart_toEndOf="@id/apps_icon"
app:layout_constraintTop_toTopOf="parent"
tools:text="128 apps" />

Expand All @@ -46,6 +50,7 @@
style="@style/TextAppearance.Material3.BodySmall"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:singleLine="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/install_action"
Expand Down
Loading

0 comments on commit 243a55f

Please sign in to comment.