From 243a55f8c7b2e61b6f7be60bd041b3b8f0b3746f Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 10 Sep 2024 19:44:58 +0200 Subject: [PATCH] Add battery level alerts logic and UI to config a threshold (worker and notification still missing) --- .../darken/octi/common/TextViewExtensions.kt | 10 ++ .../darken/octi/common/flow/throttleLatest.kt | 14 ++ .../octi/main/ui/dashboard/DashboardVM.kt | 22 ++- .../modules/power/ui/PowerInfoExtensions.kt | 2 +- .../power/ui/alerts/PowerAlertsAction.kt | 3 + .../power/ui/alerts/PowerAlertsFragment.kt | 68 ++++++++ .../modules/power/ui/alerts/PowerAlertsVM.kt | 70 +++++++++ .../power/ui/dashboard/DevicePowerVH.kt | 23 ++- .../res/layout/dashboard_device_apps_item.xml | 15 +- .../dashboard_device_clipboard_item.xml | 10 +- .../layout/dashboard_device_power_item.xml | 42 ++++- .../res/layout/dashboard_device_wifi_item.xml | 15 +- .../layout/module_power_alerts_fragment.xml | 109 +++++++++++++ app/src/main/res/navigation/main.xml | 12 +- app/src/main/res/values/styles.xml | 6 +- .../octi/modules/power/core/PowerSettings.kt | 5 + .../modules/power/core/alerts/PowerAlert.kt | 19 +++ .../power/core/alerts/PowerAlertManager.kt | 147 ++++++++++++++++++ .../drawable/ic_baseline_battery_0_bar_24.xml | 0 .../drawable/ic_baseline_battery_1_bar_24.xml | 0 .../drawable/ic_baseline_battery_2_bar_24.xml | 0 .../drawable/ic_baseline_battery_3_bar_24.xml | 0 .../drawable/ic_baseline_battery_4_bar_24.xml | 0 .../drawable/ic_baseline_battery_5_bar_24.xml | 0 .../drawable/ic_baseline_battery_6_bar_24.xml | 0 .../drawable/ic_baseline_battery_alert_24.xml | 0 .../ic_baseline_battery_charging_full_24.xml | 0 .../drawable/ic_baseline_battery_full_24.xml | 0 .../ic_baseline_battery_unknown_24.xml | 0 .../res/drawable/ic_battery_arrow_down_24.xml | 10 ++ .../src/main/res/drawable/ic_bell_cog_24.xml | 10 ++ .../main/res/drawable/ic_bell_outline_24.xml | 10 ++ modules-power/src/main/res/values/strings.xml | 6 + 33 files changed, 596 insertions(+), 32 deletions(-) create mode 100644 app-common/src/main/java/eu/darken/octi/common/TextViewExtensions.kt create mode 100644 app-common/src/main/java/eu/darken/octi/common/flow/throttleLatest.kt create mode 100644 app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsAction.kt create mode 100644 app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsFragment.kt create mode 100644 app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsVM.kt create mode 100644 app/src/main/res/layout/module_power_alerts_fragment.xml create mode 100644 modules-power/src/main/java/eu/darken/octi/modules/power/core/alerts/PowerAlert.kt create mode 100644 modules-power/src/main/java/eu/darken/octi/modules/power/core/alerts/PowerAlertManager.kt rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_0_bar_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_1_bar_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_2_bar_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_3_bar_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_4_bar_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_5_bar_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_6_bar_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_alert_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_charging_full_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_full_24.xml (100%) rename {app => modules-power}/src/main/res/drawable/ic_baseline_battery_unknown_24.xml (100%) create mode 100644 modules-power/src/main/res/drawable/ic_battery_arrow_down_24.xml create mode 100644 modules-power/src/main/res/drawable/ic_bell_cog_24.xml create mode 100644 modules-power/src/main/res/drawable/ic_bell_outline_24.xml diff --git a/app-common/src/main/java/eu/darken/octi/common/TextViewExtensions.kt b/app-common/src/main/java/eu/darken/octi/common/TextViewExtensions.kt new file mode 100644 index 00000000..955e1eaa --- /dev/null +++ b/app-common/src/main/java/eu/darken/octi/common/TextViewExtensions.kt @@ -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) + } \ No newline at end of file diff --git a/app-common/src/main/java/eu/darken/octi/common/flow/throttleLatest.kt b/app-common/src/main/java/eu/darken/octi/common/flow/throttleLatest.kt new file mode 100644 index 00000000..4b3922c5 --- /dev/null +++ b/app-common/src/main/java/eu/darken/octi/common/flow/throttleLatest.kt @@ -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 Flow.throttleLatest(delayMillis: Long): Flow = this + .conflate() + .transform { + emit(it) + delay(delayMillis) + } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/main/ui/dashboard/DashboardVM.kt b/app/src/main/java/eu/darken/octi/main/ui/dashboard/DashboardVM.kt index 9b0f6319..8505ae04 100644 --- a/app/src/main/java/eu/darken/octi/main/ui/dashboard/DashboardVM.kt +++ b/app/src/main/java/eu/darken/octi/main/ui/dashboard/DashboardVM.kt @@ -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 @@ -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 @@ -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 { @@ -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 + val metaModule = moduleDatas.firstOrNull { it.data is MetaInfo } as? ModuleData 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).createVHItem() + is PowerInfo -> (moduleData as ModuleData).createVHItem(powerAlerts) is WifiInfo -> (moduleData as ModuleData).createVHItem(missingPermissions) is AppsInfo -> (moduleData as ModuleData).createVHItem() is ClipboardInfo -> (moduleData as ModuleData).createVHItem() @@ -253,8 +257,14 @@ class DashboardVM @Inject constructor( private val ModuleData.orderPrio: Int get() = INFO_ORDER.indexOfFirst { it.isInstance(this.data) } - private fun ModuleData.createVHItem() = DevicePowerVH.Item( + private fun ModuleData.createVHItem( + powerAlerts: Collection + ): DevicePowerVH.Item = DevicePowerVH.Item( data = this, + batteryLowAlert = powerAlerts.filterIsInstance().firstOrNull(), + onSettingsAction = { + DashboardFragmentDirections.actionDashFragmentToPowerAlertsFragment(deviceId).navigate() + }.takeIf { deviceId != syncSettings.deviceId }, ) private fun ModuleData.createVHItem( diff --git a/app/src/main/java/eu/darken/octi/modules/power/ui/PowerInfoExtensions.kt b/app/src/main/java/eu/darken/octi/modules/power/ui/PowerInfoExtensions.kt index 2c92bd54..323b2174 100644 --- a/app/src/main/java/eu/darken/octi/modules/power/ui/PowerInfoExtensions.kt +++ b/app/src/main/java/eu/darken/octi/modules/power/ui/PowerInfoExtensions.kt @@ -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 diff --git a/app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsAction.kt b/app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsAction.kt new file mode 100644 index 00000000..585e8fa6 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsAction.kt @@ -0,0 +1,3 @@ +package eu.darken.octi.modules.power.ui.alerts + +sealed interface PowerAlertsAction \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsFragment.kt b/app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsFragment.kt new file mode 100644 index 00000000..532326ec --- /dev/null +++ b/app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsFragment.kt @@ -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) + } +} diff --git a/app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsVM.kt b/app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsVM.kt new file mode 100644 index 00000000..7785f304 --- /dev/null +++ b/app/src/main/java/eu/darken/octi/modules/power/ui/alerts/PowerAlertsVM.kt @@ -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() + + 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") + } +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/octi/modules/power/ui/dashboard/DevicePowerVH.kt b/app/src/main/java/eu/darken/octi/modules/power/ui/dashboard/DevicePowerVH.kt index 281268c3..2e57ecb2 100644 --- a/app/src/main/java/eu/darken/octi/modules/power/ui/dashboard/DevicePowerVH.kt +++ b/app/src/main/java/eu/darken/octi/modules/power/ui/dashboard/DevicePowerVH.kt @@ -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 @@ -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 } } @@ -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 -> { @@ -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, @@ -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, @@ -109,6 +121,7 @@ class DevicePowerVH(parent: ViewGroup) : ) ) } + else -> getString(R.string.module_power_battery_estimation_na) } @@ -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, + val batteryLowAlert: BatteryLowAlert?, + val onSettingsAction: (() -> Unit)?, ) : PerDeviceModuleAdapter.Item { override val stableId: Long = data.moduleId.hashCode().toLong() } diff --git a/app/src/main/res/layout/dashboard_device_apps_item.xml b/app/src/main/res/layout/dashboard_device_apps_item.xml index d26d1d17..5764ebaf 100644 --- a/app/src/main/res/layout/dashboard_device_apps_item.xml +++ b/app/src/main/res/layout/dashboard_device_apps_item.xml @@ -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"> @@ -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" diff --git a/app/src/main/res/layout/dashboard_device_clipboard_item.xml b/app/src/main/res/layout/dashboard_device_clipboard_item.xml index e59d8269..5dd83c84 100644 --- a/app/src/main/res/layout/dashboard_device_clipboard_item.xml +++ b/app/src/main/res/layout/dashboard_device_clipboard_item.xml @@ -9,8 +9,9 @@ + android:layout_height="wrap_content"> + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dashboard_device_wifi_item.xml b/app/src/main/res/layout/dashboard_device_wifi_item.xml index ec6b51b7..7f624521 100644 --- a/app/src/main/res/layout/dashboard_device_wifi_item.xml +++ b/app/src/main/res/layout/dashboard_device_wifi_item.xml @@ -2,14 +2,15 @@ + android:layout_height="wrap_content"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main.xml b/app/src/main/res/navigation/main.xml index 23e69956..57f8c57d 100644 --- a/app/src/main/res/navigation/main.xml +++ b/app/src/main/res/navigation/main.xml @@ -25,6 +25,9 @@ + - + + + \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 72f01762..7a79f86f 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -9,10 +9,7 @@