From 9588fa87e66d54e9e8049dcc8c1713d0fdbc5181 Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:06:33 +0530 Subject: [PATCH 1/3] feat(duo): add subtle battery progress wiggle charging animation - Animate subtle damped spring wiggle on battery progress upon power connection - Render electric bolt glyph in the cutout orbital gap with dynamic charging accent - Settle static immediately after initial wiggle to prevent constant visual distraction - Guard fullscreen detection to preserve Duo rendering in portrait orientation - Add user toggle in Duo settings under What to Show --- .../data/repository/SettingsRepository.kt | 7 +- .../domain/registry/FeatureRegistry.kt | 3 +- .../services/handlers/DuoOverlayHandler.kt | 106 +++++++++++-- .../tiles/ScreenOffAccessibilityService.kt | 23 ++- .../ui/features/display/DuoSettingsUI.kt | 11 ++ .../essentials/utils/DuoOverlayView.kt | 144 +++++++++++++++++- .../essentials/viewmodels/MainViewModel.kt | 28 ++-- app/src/main/res/values/strings.xml | 2 + 8 files changed, 294 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index a72a69900..ec1c18955 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -368,6 +368,7 @@ class SettingsRepository( const val KEY_DUO_SHOW_MEDIA = "duo_show_media" const val KEY_DUO_SHOW_PROGRESS = "duo_show_progress" const val KEY_DUO_SHOW_FLASHLIGHT = "duo_show_flashlight" + const val KEY_DUO_SHOW_CHARGING_SURGE = "duo_show_charging_surge" const val KEY_DUO_HIDE_WHEN_SCREEN_OFF = "duo_hide_when_screen_off" const val KEY_DUO_HIDE_WHEN_SCREEN_OFF_ONLY_IDLE = "duo_hide_when_screen_off_only_idle" const val KEY_DUO_USE_MATERIAL_YOU = "duo_use_material_you" @@ -1407,8 +1408,9 @@ class SettingsRepository( ): Boolean { return try { val json = inputStream.bufferedReader().use { it.readText() } + val type = object : com.google.gson.reflect.TypeToken>>>() {}.type val allConfigs: Map>> = - gson.fromJson(json, Map::class.java) as Map>> + gson.fromJson(json, type) ?: emptyMap() allConfigs.forEach { (fileName, prefWrapper) -> val p = context.getSharedPreferences(fileName, Context.MODE_PRIVATE) @@ -3108,6 +3110,9 @@ class SettingsRepository( fun isDuoShowFlashlightEnabled(): Boolean = getBoolean(KEY_DUO_SHOW_FLASHLIGHT, true) fun setDuoShowFlashlightEnabled(enabled: Boolean) = putBoolean(KEY_DUO_SHOW_FLASHLIGHT, enabled) + fun isDuoShowChargingSurgeEnabled(): Boolean = getBoolean(KEY_DUO_SHOW_CHARGING_SURGE, true) + fun setDuoShowChargingSurgeEnabled(enabled: Boolean) = putBoolean(KEY_DUO_SHOW_CHARGING_SURGE, enabled) + fun isDuoHideWhenScreenOffEnabled(): Boolean = getBoolean(KEY_DUO_HIDE_WHEN_SCREEN_OFF, true) fun setDuoHideWhenScreenOffEnabled(enabled: Boolean) = putBoolean(KEY_DUO_HIDE_WHEN_SCREEN_OFF, enabled) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 950230832..0c07f957d 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -1374,8 +1374,7 @@ object FeatureRegistry { parentFeatureId = "Display", ) { override fun isDeviceSupported(context: Context): Boolean { - val settingsRepository = SettingsRepository(context) - return settingsRepository.getBoolean(SettingsRepository.KEY_DEVELOPER_MODE_ENABLED, false) + return true } override fun isEnabled(viewModel: MainViewModel) = viewModel.isDuoEnabled.value diff --git a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt index e60269bfd..bdc932707 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/handlers/DuoOverlayHandler.kt @@ -165,15 +165,57 @@ class DuoOverlayHandler( } } + private var lastChargingState = false + private val batteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == Intent.ACTION_BATTERY_CHANGED) { + val action = intent?.action ?: return + if (action == Intent.ACTION_BATTERY_CHANGED || + action == Intent.ACTION_POWER_CONNECTED || + action == Intent.ACTION_POWER_DISCONNECTED + ) { val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) - val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100) - if (level >= 0 && scale > 0) { - val batteryPct = (level * 100) / scale - overlayView?.batteryLevel = batteryPct - } + val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, 100) + if (level >= 0 && scale > 0) { + val batteryPct = (level * 100) / scale + overlayView?.batteryLevel = batteryPct + } + + val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + val isChargingStatus = status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + + val plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) + val isPlugged = plugged == BatteryManager.BATTERY_PLUGGED_AC || + plugged == BatteryManager.BATTERY_PLUGGED_USB || + plugged == BatteryManager.BATTERY_PLUGGED_WIRELESS + + val isNowCharging = (isChargingStatus && isPlugged) || action == Intent.ACTION_POWER_CONNECTED + + if (action == Intent.ACTION_POWER_DISCONNECTED || (!isNowCharging && lastChargingState)) { + lastChargingState = false + overlayView?.onPowerDisconnected() + } else if (isNowCharging) { + val voltageMv = intent.getIntExtra(BatteryManager.EXTRA_VOLTAGE, -1) + val batteryManager = service.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + val currentNowUa = batteryManager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) ?: 0 + + val wattage: Float? = if (voltageMv > 0 && currentNowUa != 0 && currentNowUa != Int.MIN_VALUE) { + val currentMa = Math.abs(currentNowUa) / 1000f + val volts = voltageMv / 1000f + (volts * (currentMa / 1000f)) + } else null + + val isFastCharging = (wattage != null && wattage >= 15f) || + plugged == BatteryManager.BATTERY_PLUGGED_AC + + if (!lastChargingState || action == Intent.ACTION_POWER_CONNECTED) { + lastChargingState = true + overlayView?.triggerChargingSurge(isFastCharging, wattage) + } else { + overlayView?.setChargingState(true, isFastCharging, wattage) + } + } } } } @@ -214,8 +256,7 @@ class DuoOverlayHandler( } fun updateState() { - val isDevMode = settingsRepository.getBoolean(SettingsRepository.KEY_DEVELOPER_MODE_ENABLED, false) - if (!isDevMode || !settingsRepository.isDuoEnabled()) { + if (!settingsRepository.isDuoEnabled()) { removeOverlay() return } @@ -553,7 +594,8 @@ class DuoOverlayHandler( private fun showOrUpdateOverlay() { mainHandler.post { - val wm = windowManager ?: return@post + val wm = windowManager ?: (service.getSystemService(AccessibilityService.WINDOW_SERVICE) as? WindowManager) ?: return@post + windowManager = wm val displayMetrics = DisplayMetrics() @Suppress("DEPRECATION") @@ -567,11 +609,26 @@ class DuoOverlayHandler( var cameraRadiusPx = 18f * density * settingsRepository.getDuoCameraSize() @Suppress("DEPRECATION") - val rotation = wm.defaultDisplay.rotation + val rotation = try { + wm.defaultDisplay.rotation + } catch (_: Exception) { + android.view.Surface.ROTATION_0 + } if (settingsRepository.isDuoAutoDetectEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - @Suppress("DEPRECATION") - val cutout = wm.defaultDisplay.cutout + val cutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + try { + wm.currentWindowMetrics.windowInsets.displayCutout + } catch (_: Exception) { + null + } ?: run { + @Suppress("DEPRECATION") + try { wm.defaultDisplay.cutout } catch (_: Exception) { null } + } + } else { + @Suppress("DEPRECATION") + try { wm.defaultDisplay.cutout } catch (_: Exception) { null } + } if (cutout != null && cutout.boundingRects.isNotEmpty()) { val targetRect = when (rotation) { android.view.Surface.ROTATION_90 -> { @@ -651,6 +708,7 @@ class DuoOverlayHandler( this.showMedia = settingsRepository.isDuoShowMediaEnabled() this.showProgress = settingsRepository.isDuoShowProgressEnabled() this.showFlashlight = settingsRepository.isDuoShowFlashlightEnabled() + this.showChargingSurge = settingsRepository.isDuoShowChargingSurgeEnabled() } if (!isOverlayAdded) { @@ -662,6 +720,11 @@ class DuoOverlayHandler( WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, isTouchable = false ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + params.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + params.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES + } try { wm.addView(overlayView, params) isOverlayAdded = true @@ -823,9 +886,14 @@ class DuoOverlayHandler( private fun registerBatteryReceiver() { if (!isBatteryReceiverRegistered) { try { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_BATTERY_CHANGED) + addAction(Intent.ACTION_POWER_CONNECTED) + addAction(Intent.ACTION_POWER_DISCONNECTED) + } val intent = service.registerReceiver( batteryReceiver, - IntentFilter(Intent.ACTION_BATTERY_CHANGED) + filter ) isBatteryReceiverRegistered = true intent?.let { @@ -834,6 +902,18 @@ class DuoOverlayHandler( if (level >= 0 && scale > 0) { overlayView?.batteryLevel = (level * 100) / scale } + val status = it.getIntExtra(BatteryManager.EXTRA_STATUS, -1) + val isChargingStatus = status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL + val plugged = it.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1) + val isPlugged = plugged == BatteryManager.BATTERY_PLUGGED_AC || + plugged == BatteryManager.BATTERY_PLUGGED_USB || + plugged == BatteryManager.BATTERY_PLUGGED_WIRELESS + if (isChargingStatus && isPlugged) { + lastChargingState = true + val isFast = plugged == BatteryManager.BATTERY_PLUGGED_AC + overlayView?.setChargingState(true, isFast, null) + } } } catch (e: Exception) { Log.e("DuoOverlayHandler", "Failed to register battery receiver", e) diff --git a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt index 9b25f3694..e83845e2e 100644 --- a/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt +++ b/app/src/main/java/com/sameerasw/essentials/services/tiles/ScreenOffAccessibilityService.kt @@ -25,6 +25,8 @@ import android.os.Handler import android.os.Looper import android.os.Vibrator import android.view.KeyEvent +import android.view.Surface +import android.view.WindowManager import android.view.accessibility.AccessibilityEvent import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.domain.HapticFeedbackType @@ -495,6 +497,19 @@ class ScreenOffAccessibilityService : private fun checkFullscreenState() { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { try { + val wm = getSystemService(Context.WINDOW_SERVICE) as? WindowManager + @Suppress("DEPRECATION") + val rotation = try { + wm?.defaultDisplay?.rotation ?: Surface.ROTATION_0 + } catch (_: Exception) { + Surface.ROTATION_0 + } + // In portrait orientation, the camera cutout is at the top of the display and Duo must stay visible + if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) { + duoOverlayHandler.setFullscreen(false) + return + } + val currentWindows = windows if (!currentWindows.isNullOrEmpty()) { val hasStatusBar = currentWindows.any { it.type == android.view.accessibility.AccessibilityWindowInfo.TYPE_SYSTEM } @@ -510,9 +525,15 @@ class ScreenOffAccessibilityService : val isFullscreen = isCoveringFullDisplay && !hasStatusBar duoOverlayHandler.setFullscreen(isFullscreen) + } else { + duoOverlayHandler.setFullscreen(false) } + } else { + duoOverlayHandler.setFullscreen(false) } - } catch (_: Exception) {} + } catch (_: Exception) { + duoOverlayHandler.setFullscreen(false) + } } } diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt index 9b63acd87..e2b5dcf58 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/DuoSettingsUI.kt @@ -298,6 +298,17 @@ fun DuoSettingsUI( }, modifier = Modifier.highlight(highlightSetting == "duo_show_flashlight"), ) + IconToggleItem( + iconRes = R.drawable.rounded_bolt_24, + title = stringResource(R.string.duo_show_charging_surge_title), + description = stringResource(R.string.duo_show_charging_surge_desc), + isChecked = viewModel.isDuoShowChargingSurge.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setDuoShowChargingSurge(checked) + }, + modifier = Modifier.highlight(highlightSetting == "duo_show_charging_surge"), + ) } Text( diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt index 803c6253d..f357c54f7 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt @@ -9,6 +9,8 @@ package com.sameerasw.essentials.utils +import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.content.Context @@ -22,11 +24,13 @@ import android.graphics.PorterDuffColorFilter import android.graphics.RectF import android.os.Build import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.DecelerateInterpolator import android.view.animation.LinearInterpolator import android.view.animation.OvershootInterpolator import androidx.core.content.ContextCompat import androidx.palette.graphics.Palette +import com.sameerasw.essentials.R import kotlin.math.cos import kotlin.math.sin @@ -220,6 +224,24 @@ class DuoOverlayView(context: Context) : View(context) { var flashlightBrightnessProgress: Float = 100f private set + var showChargingSurge: Boolean = true + + var isCharging: Boolean = false + private set + + var isFastCharging: Boolean = false + private set + + var chargingWattage: Float? = null + private set + + private var animatedBoltScale: Float = 0f + private var animatedProgressWiggle: Float = 0f + + private var boltAnimator: ValueAnimator? = null + private var wiggleAnimator: ValueAnimator? = null + + private val boltDrawable by lazy { ContextCompat.getDrawable(context, R.drawable.rounded_bolt_24) } private var mediaPaletteColors: Pair? = null private fun extractMediaColors(bitmap: Bitmap): Pair { @@ -398,6 +420,86 @@ class DuoOverlayView(context: Context) : View(context) { } } + fun triggerChargingSurge(isFastCharging: Boolean, wattage: Float?) { + isCharging = true + this.isFastCharging = isFastCharging + this.chargingWattage = wattage + + animateThemeChange() + + if (showChargingSurge) { + boltAnimator?.cancel() + boltAnimator = ValueAnimator.ofFloat(animatedBoltScale, 1.0f).apply { + duration = 450 + interpolator = OvershootInterpolator(1.3f) + addUpdateListener { animation -> + animatedBoltScale = animation.animatedValue as Float + invalidate() + } + start() + } + + startBatteryProgressWiggle() + } else { + animatedBoltScale = 1.0f + invalidate() + } + } + + fun setChargingState(isCharging: Boolean, isFastCharging: Boolean, wattage: Float?) { + val changed = this.isCharging != isCharging || this.isFastCharging != isFastCharging + this.isCharging = isCharging + this.isFastCharging = isFastCharging + this.chargingWattage = wattage + if (changed) { + animateThemeChange() + if (isCharging) { + startBatteryProgressWiggle() + } + } + } + + fun onPowerDisconnected() { + isCharging = false + isFastCharging = false + chargingWattage = null + wiggleAnimator?.cancel() + animatedProgressWiggle = 0f + + boltAnimator?.cancel() + boltAnimator = ValueAnimator.ofFloat(animatedBoltScale, 0f).apply { + duration = 350 + interpolator = DecelerateInterpolator() + addUpdateListener { animation -> + animatedBoltScale = animation.animatedValue as Float + invalidate() + } + start() + } + + animateThemeChange() + invalidate() + } + + private fun startBatteryProgressWiggle() { + wiggleAnimator?.cancel() + wiggleAnimator = ValueAnimator.ofFloat(0f, 6.0f, -3.5f, 1.8f, -0.6f, 0f).apply { + duration = 850 + interpolator = DecelerateInterpolator() + addUpdateListener { animation -> + animatedProgressWiggle = animation.animatedValue as Float + invalidate() + } + addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + animatedProgressWiggle = 0f + invalidate() + } + }) + start() + } + } + private var targetProgress: Float = 100f private var animatedProgress: Float = 100f private var progressAnimator: ValueAnimator? = null @@ -460,6 +562,12 @@ class DuoOverlayView(context: Context) : View(context) { val dimTrack = Color.argb(50, Color.red(accent), Color.green(accent), Color.blue(accent)) return Triple(dimTrack, dimProgress, dimProgress) } + if (isCharging) { + val chargeAccent = if (isFastCharging) Color.rgb(0, 229, 255) else Color.rgb(0, 230, 118) + val dimProgress = Color.argb(160, Color.red(chargeAccent), Color.green(chargeAccent), Color.blue(chargeAccent)) + val dimTrack = Color.argb(50, Color.red(chargeAccent), Color.green(chargeAccent), Color.blue(chargeAccent)) + return Triple(dimTrack, dimProgress, dimProgress) + } return Triple( Color.argb(40, 255, 255, 255), Color.argb(128, 255, 255, 255), @@ -472,6 +580,13 @@ class DuoOverlayView(context: Context) : View(context) { return Triple(track, progress, progress) } + if (isCharging) { + val chargeAccent = if (isFastCharging) Color.rgb(0, 229, 255) else Color.rgb(0, 230, 118) + val trackAlpha = if (isDarkTheme) 90 else 110 + val track = Color.argb(trackAlpha, Color.red(chargeAccent), Color.green(chargeAccent), Color.blue(chargeAccent)) + return Triple(track, chargeAccent, chargeAccent) + } + if (useMaterialYouColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { return if (isDarkTheme) { val accent = ContextCompat.getColor(context, android.R.color.system_accent1_200) @@ -662,7 +777,8 @@ class DuoOverlayView(context: Context) : View(context) { ) canvas.drawArc(arcBounds, animatedStartAngle, animatedTotalSweep, false, trackPaint) - val progressSweep = (animatedProgress.coerceIn(0f, 100f) / 100f) * animatedTotalSweep + val effectiveProgress = (animatedProgress + animatedProgressWiggle).coerceIn(0f, 100f) + val progressSweep = (effectiveProgress / 100f) * animatedTotalSweep if (progressSweep > 0.5f) { val progAlpha = (Color.alpha(currentProgressColor) * animatedVisibilityAlpha).toInt() progressPaint.color = Color.argb( @@ -696,6 +812,30 @@ class DuoOverlayView(context: Context) : View(context) { } } + + + // Electric Bolt in Gap + if (animatedBoltScale > 0.01f && animatedCustomFraction < 0.2f) { + val gapCenterAngleDeg = (animatedStartAngle + animatedTotalSweep + (360f - animatedTotalSweep) / 2f) % 360f + val angleRad = Math.toRadians(gapCenterAngleDeg.toDouble()) + val downwardOffset = 4f * resources.displayMetrics.density + val boltCenterX = (cameraCenterX + (baseRadius + downwardOffset) * cos(angleRad)).toFloat() + val boltCenterY = (cameraCenterY + (baseRadius + downwardOffset) * sin(angleRad)).toFloat() + + val boltRadius = (dotRadiusPx * 2.85f) * animatedBoltScale * (1f - animatedCustomFraction) + if (boltRadius > 1f && boltDrawable != null) { + val b = boltDrawable!! + val left = (boltCenterX - boltRadius).toInt() + val top = (boltCenterY - boltRadius).toInt() + val right = (boltCenterX + boltRadius).toInt() + val bottom = (boltCenterY + boltRadius).toInt() + b.setBounds(left, top, right, bottom) + b.setTint(currentProgressColor) + b.alpha = (255 * animatedBoltScale * animatedVisibilityAlpha).toInt() + b.draw(canvas) + } + } + if (animatedCustomFraction > 0.01f) { val icon = getCurrentCustomIcon() if (icon != null) { @@ -741,6 +881,8 @@ class DuoOverlayView(context: Context) : View(context) { scaleAnimator?.cancel() themeAnimator?.cancel() visibilityAnimator?.cancel() + boltAnimator?.cancel() + wiggleAnimator?.cancel() } } diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index af19910e5..582734400 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -135,6 +135,7 @@ class MainViewModel : ViewModel() { val isDuoShowMedia = mutableStateOf(true) val isDuoShowProgress = mutableStateOf(true) val isDuoShowFlashlight = mutableStateOf(true) + val isDuoShowChargingSurge = mutableStateOf(true) val isDuoHideWhenScreenOff = mutableStateOf(true) val isDuoHideWhenScreenOffOnlyIdle = mutableStateOf(false) val isDuoUseMaterialYou = mutableStateOf(true) @@ -554,6 +555,9 @@ class MainViewModel : ViewModel() { SettingsRepository.KEY_DUO_SHOW_FLASHLIGHT -> isDuoShowFlashlight.value = settingsRepository.isDuoShowFlashlightEnabled() + SettingsRepository.KEY_DUO_SHOW_CHARGING_SURGE -> + isDuoShowChargingSurge.value = settingsRepository.isDuoShowChargingSurgeEnabled() + SettingsRepository.KEY_DUO_HIDE_WHEN_SCREEN_OFF -> isDuoHideWhenScreenOff.value = settingsRepository.isDuoHideWhenScreenOffEnabled() @@ -1797,6 +1801,7 @@ class MainViewModel : ViewModel() { isDuoShowMedia.value = settingsRepository.isDuoShowMediaEnabled() isDuoShowProgress.value = settingsRepository.isDuoShowProgressEnabled() isDuoShowFlashlight.value = settingsRepository.isDuoShowFlashlightEnabled() + isDuoShowChargingSurge.value = settingsRepository.isDuoShowChargingSurgeEnabled() isDuoHideWhenScreenOff.value = settingsRepository.isDuoHideWhenScreenOffEnabled() isDuoHideWhenScreenOffOnlyIdle.value = settingsRepository.isDuoHideWhenScreenOffOnlyIdleEnabled() isDuoUseMaterialYou.value = settingsRepository.isDuoUseMaterialYouEnabled() @@ -2327,9 +2332,6 @@ class MainViewModel : ViewModel() { // Enabling pre-releases automatically enables Developer Mode; disabling turns it off isDeveloperModeEnabled.value = enabled settingsRepository.putBooleanSync(SettingsRepository.KEY_DEVELOPER_MODE_ENABLED, enabled) - if (!enabled) { - setDuoEnabled(false) - } } /** @@ -2344,9 +2346,6 @@ class MainViewModel : ViewModel() { ) { isDeveloperModeEnabled.value = enabled settingsRepository.putBoolean(SettingsRepository.KEY_DEVELOPER_MODE_ENABLED, enabled) - if (!enabled) { - setDuoEnabled(false) - } } /** @@ -3805,7 +3804,7 @@ class MainViewModel : ViewModel() { val sessions = manager.getActiveSessions(componentName) val activeSession = sessions - ?.sortedWith( + .sortedWith( compareByDescending { val state = it.playbackState?.state state == android.media.session.PlaybackState.STATE_PLAYING || @@ -3813,8 +3812,8 @@ class MainViewModel : ViewModel() { }.thenByDescending { val state = it.playbackState?.state state == android.media.session.PlaybackState.STATE_PAUSED - }, - )?.firstOrNull() + } + ).firstOrNull() if (activeSession != null) { val metadata = activeSession.metadata @@ -4413,6 +4412,11 @@ class MainViewModel : ViewModel() { settingsRepository.setDuoShowFlashlightEnabled(enabled) } + fun setDuoShowChargingSurge(enabled: Boolean) { + isDuoShowChargingSurge.value = enabled + settingsRepository.setDuoShowChargingSurgeEnabled(enabled) + } + fun setDuoHideWhenScreenOff(enabled: Boolean) { isDuoHideWhenScreenOff.value = enabled settingsRepository.setDuoHideWhenScreenOffEnabled(enabled) @@ -7115,9 +7119,9 @@ class MainViewModel : ViewModel() { } else { val backupData = gson.fromJson(json, FreezeBackupData::class.java) if (backupData != null) { - importedApps = backupData.apps ?: emptyList() - importedTags = backupData.tags ?: emptyList() - importedMap = backupData.appTagMap ?: emptyMap() + importedApps = backupData.apps + importedTags = backupData.tags + importedMap = backupData.appTagMap } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d6fd21c93..a6eb69c8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2478,6 +2478,8 @@ Display ongoing task and download progress around the indicator Flashlight Display flashlight state and brightness level around the indicator + Charging animation + Wiggle battery progress when connected to power Behavior Use Material You colors Use accent color from wallpaper From be0f23b33ce386e0a07a0b479d15d4873215ff9a Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:33:44 +0530 Subject: [PATCH 2/3] feat(duo): add liquid progress surge and elastic breath to one-time charging entrance --- .../essentials/utils/DuoOverlayView.kt | 122 +++++++++++++++--- app/src/main/res/values/strings.xml | 11 +- 2 files changed, 109 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt index f357c54f7..b84f3fa29 100644 --- a/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt +++ b/app/src/main/java/com/sameerasw/essentials/utils/DuoOverlayView.kt @@ -79,7 +79,7 @@ class DuoOverlayView(context: Context) : View(context) { set(value) { val clamped = value.coerceIn(0, 100) field = clamped - if (!isCustomProgressActive()) { + if (!isCustomProgressActive() && chargingSurgeAnimator?.isRunning != true) { updateProgressAnimation(clamped.toFloat()) } } @@ -237,9 +237,11 @@ class DuoOverlayView(context: Context) : View(context) { private var animatedBoltScale: Float = 0f private var animatedProgressWiggle: Float = 0f + private var chargingGlowAlpha: Float = 0f private var boltAnimator: ValueAnimator? = null private var wiggleAnimator: ValueAnimator? = null + private var chargingSurgeAnimator: ValueAnimator? = null private val boltDrawable by lazy { ContextCompat.getDrawable(context, R.drawable.rounded_bolt_24) } private var mediaPaletteColors: Pair? = null @@ -428,33 +430,27 @@ class DuoOverlayView(context: Context) : View(context) { animateThemeChange() if (showChargingSurge) { - boltAnimator?.cancel() - boltAnimator = ValueAnimator.ofFloat(animatedBoltScale, 1.0f).apply { - duration = 450 - interpolator = OvershootInterpolator(1.3f) - addUpdateListener { animation -> - animatedBoltScale = animation.animatedValue as Float - invalidate() - } - start() - } - - startBatteryProgressWiggle() + startPremiumChargingSurge() } else { animatedBoltScale = 1.0f + chargingGlowAlpha = 0f + animatedProgress = getActiveTargetProgress() + animatedProgressWiggle = 0f + animatedScaleBounce = 1.0f invalidate() } } fun setChargingState(isCharging: Boolean, isFastCharging: Boolean, wattage: Float?) { + val wasCharging = this.isCharging val changed = this.isCharging != isCharging || this.isFastCharging != isFastCharging this.isCharging = isCharging this.isFastCharging = isFastCharging this.chargingWattage = wattage if (changed) { animateThemeChange() - if (isCharging) { - startBatteryProgressWiggle() + if (isCharging && !wasCharging && showChargingSurge) { + startPremiumChargingSurge() } } } @@ -463,8 +459,12 @@ class DuoOverlayView(context: Context) : View(context) { isCharging = false isFastCharging = false chargingWattage = null + chargingSurgeAnimator?.cancel() wiggleAnimator?.cancel() animatedProgressWiggle = 0f + animatedScaleBounce = 1.0f + chargingGlowAlpha = 0f + animatedProgress = getActiveTargetProgress() boltAnimator?.cancel() boltAnimator = ValueAnimator.ofFloat(animatedBoltScale, 0f).apply { @@ -481,18 +481,77 @@ class DuoOverlayView(context: Context) : View(context) { invalidate() } - private fun startBatteryProgressWiggle() { + private fun startPremiumChargingSurge() { + chargingSurgeAnimator?.cancel() wiggleAnimator?.cancel() - wiggleAnimator = ValueAnimator.ofFloat(0f, 6.0f, -3.5f, 1.8f, -0.6f, 0f).apply { - duration = 850 - interpolator = DecelerateInterpolator() - addUpdateListener { animation -> - animatedProgressWiggle = animation.animatedValue as Float + boltAnimator?.cancel() + scaleAnimator?.cancel() + + val target = getActiveTargetProgress() + val startProgress = 0f + + chargingSurgeAnimator = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 950 + interpolator = LinearInterpolator() + addUpdateListener { animator -> + val t = animator.animatedValue as Float + + // 1. Bolt Scale: spring pop up to 1.22 in first 550ms, then settling at 1.0f + if (t <= 0.55f) { + val boltNorm = t / 0.55f + val tension = 1.8f + val x = boltNorm - 1f + animatedBoltScale = ((x * x * ((tension + 1) * x + tension) + 1f)).coerceIn(0f, 1.25f) + } else { + animatedBoltScale = 1.0f + } + + // 2. Ring Elastic Scale Breath: 1.0 -> 1.075 -> 0.995 -> 1.0 + animatedScaleBounce = when { + t < 0.35f -> { + val p = t / 0.35f + 1.0f + 0.075f * sin(p * (Math.PI / 2).toFloat()) + } + t < 0.75f -> { + val p = (t - 0.35f) / 0.40f + 1.075f - 0.08f * sin(p * (Math.PI / 2).toFloat()) + } + else -> { + val p = (t - 0.75f) / 0.25f + 0.995f + 0.005f * p + } + } + + // 3. Liquid Progress Sweep & Damped Settle + if (t < 0.65f) { + val p = t / 0.65f + val decel = 1f - (1f - p) * (1f - p) + animatedProgress = (startProgress + (target + 3.5f - startProgress) * decel).coerceIn(0f, 100f) + animatedProgressWiggle = 0f + } else { + val p = (t - 0.65f) / 0.35f + val decay = 1f - p + val oscillation = sin(p * Math.PI.toFloat() * 2.5f) + animatedProgress = target + animatedProgressWiggle = 3.5f * decay * oscillation + } + + // 4. Luminous Electric Glow Flare + chargingGlowAlpha = when { + t < 0.30f -> (t / 0.30f) * 0.65f + t < 0.85f -> (1f - (t - 0.30f) / 0.55f) * 0.65f + else -> 0f + } + invalidate() } addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { + animatedProgress = target animatedProgressWiggle = 0f + animatedBoltScale = 1.0f + animatedScaleBounce = 1.0f + chargingGlowAlpha = 0f invalidate() } }) @@ -663,6 +722,9 @@ class DuoOverlayView(context: Context) : View(context) { private fun updateProgressAnimation(target: Float = getActiveTargetProgress()) { targetProgress = target + if (chargingSurgeAnimator?.isRunning == true) { + return + } if (kotlin.math.abs(animatedProgress - targetProgress) < 0.05f) { animatedProgress = targetProgress invalidate() @@ -731,6 +793,11 @@ class DuoOverlayView(context: Context) : View(context) { strokeCap = Paint.Cap.ROUND } + private val chargingGlowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + } + private val dotPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } @@ -780,6 +847,18 @@ class DuoOverlayView(context: Context) : View(context) { val effectiveProgress = (animatedProgress + animatedProgressWiggle).coerceIn(0f, 100f) val progressSweep = (effectiveProgress / 100f) * animatedTotalSweep if (progressSweep > 0.5f) { + if (chargingGlowAlpha > 0.01f) { + chargingGlowPaint.strokeWidth = progressPaint.strokeWidth * 1.85f + val glowAlpha = (Color.alpha(currentProgressColor) * chargingGlowAlpha * animatedVisibilityAlpha).toInt().coerceIn(0, 255) + chargingGlowPaint.color = Color.argb( + glowAlpha, + Color.red(currentProgressColor), + Color.green(currentProgressColor), + Color.blue(currentProgressColor) + ) + canvas.drawArc(arcBounds, animatedStartAngle, progressSweep, false, chargingGlowPaint) + } + val progAlpha = (Color.alpha(currentProgressColor) * animatedVisibilityAlpha).toInt() progressPaint.color = Color.argb( progAlpha, @@ -883,6 +962,7 @@ class DuoOverlayView(context: Context) : View(context) { visibilityAnimator?.cancel() boltAnimator?.cancel() wiggleAnimator?.cancel() + chargingSurgeAnimator?.cancel() } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 278b9277a..b53deca20 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2023,7 +2023,6 @@ Ripple animation Enable liquid ripple animations across the UI Motion blur - Amount Online help media Blur is disabled on this device to prevent a known display bug on Samsung devices with Android 15 or below. Swipe between tabs @@ -2114,7 +2113,7 @@ Standby apps Optimize app background activities Configure network download speed limits, keep mobile data continuously active even when connected to Wi-Fi, and enable wireless display certification settings. - Manage Android App Standby Buckets to control background app execution, network usage, and battery consumption.\n\n• Active - App in use or used very recently.\n• Working set - Used regularly or daily; jobs and alarms mildly deferred.\n• Frequent - Used weekly; background work deferred more.\n• Rare - Seldom used; jobs, alarms and background internet heavily limited.\n• Restricted - Lowest priority (Android 12+); about once-daily batched jobs and one alarm per day. Best battery saving, breaks timely notifications. + Manage Android App Standby Buckets (Active, Working set, Frequent, Rare, Restricted) to control background app execution, network usage, and battery consumption. Advanced display and system UI customizations including gesture bar visibility, circle to search height, rotation suggestions, setting overlays, transparent navigation bar, and GPU screen compositing. Active Working set @@ -2472,14 +2471,20 @@ Style What to show Networks + Show mobile network status indicator dots Media playback + Display playback seekbar and media player icon around the cutout Progress notifications + Display ongoing task and download progress around the indicator Flashlight Display flashlight state and brightness level around the indicator Charging animation - Wiggle battery progress when connected to power + Animate battery progress when connected to power Behavior Use Material You colors + Use accent color from wallpaper Hide when screen off + Hide the overlay on AOD Only hide on AOD while idle + Keep active media, progress notifications, or flashlight visible on AOD From a60163449cc73abb233e9d9489099daa664970da Mon Sep 17 00:00:00 2001 From: balajitechlabs <212744006+Balajitechlabs@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:02:33 +0530 Subject: [PATCH 3/3] fix: restore missing label_motion_blur_amount string resource --- app/src/main/res/values/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b53deca20..c215d9f47 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2023,6 +2023,7 @@ Ripple animation Enable liquid ripple animations across the UI Motion blur + Amount Online help media Blur is disabled on this device to prevent a known display bug on Samsung devices with Android 15 or below. Swipe between tabs @@ -2113,7 +2114,7 @@ Standby apps Optimize app background activities Configure network download speed limits, keep mobile data continuously active even when connected to Wi-Fi, and enable wireless display certification settings. - Manage Android App Standby Buckets (Active, Working set, Frequent, Rare, Restricted) to control background app execution, network usage, and battery consumption. + Manage Android App Standby Buckets to control background app execution, network usage, and battery consumption.\n\n• Active - App in use or used very recently.\n• Working set - Used regularly or daily; jobs and alarms mildly deferred.\n• Frequent - Used weekly; background work deferred more.\n• Rare - Seldom used; jobs, alarms and background internet heavily limited.\n• Restricted - Lowest priority (Android 12+); about once-daily batched jobs and one alarm per day. Best battery saving, breaks timely notifications. Advanced display and system UI customizations including gesture bar visibility, circle to search height, rotation suggestions, setting overlays, transparent navigation bar, and GPU screen compositing. Active Working set