Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
LibrePods - AirPods liberated from Apple’s ecosystem
Copyright (C) 2025 LibrePods contributors

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package me.kavishdevar.librepods.presentation.overlays

import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.util.TypedValue
import android.view.View
import android.view.animation.DecelerateInterpolator
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.ColorUtils
import me.kavishdevar.librepods.R
import java.text.NumberFormat

/** Battery level drawn the way iOS shows it in the Dynamic Island: a ring around the bare number. */
class BatteryRingView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {

private var level: Int? = null
private var sweepFraction = 0f
private var sweepAnimator: ValueAnimator? = null

private val strokeWidth = resources.getDimension(R.dimen.island_battery_ring_stroke)
private val ringBounds = RectF()

private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = this@BatteryRingView.strokeWidth
}
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = this@BatteryRingView.strokeWidth
strokeCap = Paint.Cap.ROUND
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
textSize = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 15f, resources.displayMetrics)
typeface = Typeface.create(ResourcesCompat.getFont(context, R.font.sf_pro), 600, false)
}

init {
applyColor()
}

fun setLevel(newLevel: Int?, animate: Boolean) {
if (newLevel == level && sweepAnimator?.isRunning != true) return
level = newLevel
contentDescription = newLevel?.let { NumberFormat.getPercentInstance().format(it / 100.0) }
applyColor()

val target = (newLevel ?: 0) / 100f
sweepAnimator?.cancel()
if (animate) {
sweepAnimator = ValueAnimator.ofFloat(sweepFraction, target).apply {
duration = 700
interpolator = DecelerateInterpolator(1.5f)
addUpdateListener {
sweepFraction = it.animatedValue as Float
invalidate()
}
start()
}
} else {
sweepFraction = target
invalidate()
}
}

private fun applyColor() {
val color = context.getColor(
if ((level ?: 100) <= LOW_BATTERY_LEVEL) R.color.island_battery_low else R.color.island_battery_normal
)
progressPaint.color = color
textPaint.color = color
trackPaint.color = ColorUtils.setAlphaComponent(color, 0x4D)
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val inset = strokeWidth / 2f
ringBounds.set(inset, inset, w - inset, h - inset)
}

override fun onDraw(canvas: Canvas) {
canvas.drawOval(ringBounds, trackPaint)
if (sweepFraction > 0f) {
canvas.drawArc(ringBounds, -90f, 360f * sweepFraction, false, progressPaint)
}
level?.let {
val baseline = ringBounds.centerY() - (textPaint.descent() + textPaint.ascent()) / 2f
canvas.drawText(it.toString(), ringBounds.centerX(), baseline, textPaint)
}
}

override fun onDetachedFromWindow() {
sweepAnimator?.cancel()
super.onDetachedFromWindow()
}

private companion object {
const val LOW_BATTERY_LEVEL = 20
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
LibrePods - AirPods liberated from Apple’s ecosystem
Copyright (C) 2025 LibrePods contributors

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package me.kavishdevar.librepods.presentation.overlays

import android.content.Context
import android.graphics.Color
import android.graphics.Outline
import android.graphics.RectF
import android.graphics.RenderEffect
import android.graphics.Shader
import android.util.AttributeSet
import android.view.View
import android.view.ViewOutlineProvider
import android.widget.FrameLayout
import androidx.dynamicanimation.animation.FloatPropertyCompat
import me.kavishdevar.librepods.R

/**
* The black stadium of the Dynamic Island. It morphs between a small pill below the camera
* ([expansion] 0) and its full size ([expansion] 1). Springs may overshoot past 1, which grows
* the shape into the transparent bleed around it.
*/
class IslandView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : FrameLayout(context, attrs) {

var collapsedWidth = 0f
var collapsedHeight = 0f

var expansion = 0f
set(value) {
field = value
applyExpansion()
}

private val bleed = resources.getDimension(R.dimen.island_bleed)
private val maxContentBlur = 12 * resources.displayMetrics.density
private val shape = RectF()
private lateinit var content: View

init {
setBackgroundColor(Color.BLACK)
clipToOutline = true
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.setRoundRect(
shape.left.toInt(),
shape.top.toInt(),
shape.right.toInt(),
shape.bottom.toInt(),
shape.height() / 2f
)
}
}
}

override fun onFinishInflate() {
super.onFinishInflate()
content = findViewById(R.id.island_content)
}

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
content.pivotX = content.width / 2f
content.pivotY = 0f
applyExpansion()
}

private fun applyExpansion() {
if (width == 0) return

val shapeWidth = lerp(collapsedWidth, width - 2 * bleed, expansion).coerceIn(0f, width.toFloat())
val shapeHeight = lerp(collapsedHeight, height - 2 * bleed, expansion).coerceIn(0f, height - bleed)
val left = (width - shapeWidth) / 2f
shape.set(left, bleed, left + shapeWidth, bleed + shapeHeight)
invalidateOutline()

// The pill fades in as it leaves the camera; the content follows once there is room for it.
alpha = (expansion / 0.2f).coerceIn(0f, 1f)
val reveal = ((expansion - 0.4f) / 0.6f).coerceIn(0f, 1f)
content.alpha = reveal
content.scaleX = lerp(0.85f, 1f, reveal)
content.scaleY = content.scaleX
val blur = (1f - reveal) * maxContentBlur
content.setRenderEffect(
if (blur > 0.5f) RenderEffect.createBlurEffect(blur, blur, Shader.TileMode.DECAL) else null
)
}

private fun lerp(start: Float, end: Float, fraction: Float) = start + (end - start) * fraction

companion object {
val EXPANSION = object : FloatPropertyCompat<IslandView>("expansion") {
override fun getValue(view: IslandView) = view.expansion
override fun setValue(view: IslandView, value: Float) {
view.expansion = value
}
}
}
}
Loading