= arrayOfNulls(size)
+ }
+ }
+ }
+
+ public companion object {
+ /** Returned by [divisionAt] when the position is outside the bar. */
+ public const val NO_DIVISION: Int = -1
+
+ /** Default [progressBarColor], `#5097E2`. */
+ @ColorInt
+ public const val DEFAULT_PROGRESS_COLOR: Int = 0xFF5097E2.toInt()
+
+ /** Default [progressBarBackgroundColor], `#C1C1C1`. */
+ @ColorInt
+ public const val DEFAULT_BACKGROUND_COLOR: Int = 0xFFC1C1C1.toInt()
+
+ /** Default [dividerColor], `#FFFFFF`. */
+ @ColorInt
+ public const val DEFAULT_DIVIDER_COLOR: Int = 0xFFFFFFFF.toInt()
+
+ /** Default [divisions]. */
+ public const val DEFAULT_DIVISIONS: Int = 1
+
+ /** Default [dividerWidth], in pixels. */
+ @Px
+ public const val DEFAULT_DIVIDER_WIDTH_PX: Float = 1f
+
+ /** Default [cornerRadius], in pixels. */
+ @Px
+ public const val DEFAULT_CORNER_RADIUS_PX: Float = 2f
+
+ /** Default [shadowColor], 25% black. */
+ @ColorInt
+ public const val DEFAULT_SHADOW_COLOR: Int = 0x40000000
+
+ /** Default [animationDurationMs]. */
+ public const val DEFAULT_ANIMATION_DURATION_MS: Long = 200L
+
+ /** Default [entryStaggerDelayMs]. */
+ public const val DEFAULT_ENTRY_STAGGER_DELAY_MS: Long = 60L
+
+ /** Default [recurringDurationMs]. */
+ public const val DEFAULT_RECURRING_DURATION_MS: Long = 1600L
+
+ /** Default [shimmerColor], 45% white. */
+ @ColorInt
+ public const val DEFAULT_SHIMMER_COLOR: Int = 0x73FFFFFF.toInt()
+
+ /** Sentinel for [maxWidth] and [maxHeight] meaning "unbounded". */
+ public const val NO_MAX_SIZE: Int = -1
+
+ /**
+ * How wide the shimmer highlight is, as a fraction of the bar. A little
+ * under half so the sweep reads as a moving band, not a global flash.
+ */
+ private const val SHIMMER_BAND = 0.45f
+
+ /** Peak alpha multiplier of a pulse, so it dims rather than vanishing. */
+ private const val PULSE_MIN_ALPHA = 0.45f
+
+ private const val ROUND_LEFT = 1
+ private const val ROUND_RIGHT = 2
+
+ /** Full alpha, for forcing a colour opaque. */
+ private const val ALPHA_MASK = 0xFF000000.toInt()
+
+ /** Intrinsic width used when the view is measured with `wrap_content`. */
+ private const val DEFAULT_WIDTH_DP = 144f
+
+ /** Intrinsic height used when the view is measured with `wrap_content`. */
+ private const val DEFAULT_HEIGHT_DP = 8f
+ }
+}
diff --git a/segmented/src/main/res/values/attrs.xml b/segmented/src/main/res/values/attrs.xml
index 17ddfe9..eb2ab3f 100644
--- a/segmented/src/main/res/values/attrs.xml
+++ b/segmented/src/main/res/values/attrs.xml
@@ -1,12 +1,111 @@
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/segmented/src/main/res/values/colors.xml b/segmented/src/main/res/values/colors.xml
deleted file mode 100644
index 84915ed..0000000
--- a/segmented/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
- #ffffff
- #c1c1c1
- #5097e2
-
\ No newline at end of file
diff --git a/segmented/src/main/res/values/strings.xml b/segmented/src/main/res/values/strings.xml
index ca6ab3f..cfe019e 100644
--- a/segmented/src/main/res/values/strings.xml
+++ b/segmented/src/main/res/values/strings.xml
@@ -1,3 +1,16 @@
+
- SegmentedProgressBar
+
+
+ - %1$d of %2$d segment complete
+ - %1$d of %2$d segments complete
+
diff --git a/segmented/src/test/java/com/rachitgoyal/segmented/JavaApiCompatibilityTest.java b/segmented/src/test/java/com/rachitgoyal/segmented/JavaApiCompatibilityTest.java
new file mode 100644
index 0000000..b43200d
--- /dev/null
+++ b/segmented/src/test/java/com/rachitgoyal/segmented/JavaApiCompatibilityTest.java
@@ -0,0 +1,204 @@
+package com.rachitgoyal.segmented;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.content.Context;
+import android.graphics.Color;
+
+import androidx.test.core.app.ApplicationProvider;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Deliberately written in Java, to hold two promises that Kotlin tests cannot
+ * check.
+ *
+ * First, that the library stays comfortable to use from Java: the Kotlin
+ * properties have to surface as ordinary {@code getX}/{@code setX} methods with
+ * the parameter types a Java caller expects.
+ *
+ *
Second, and more importantly, that every method 0.0.1 exposed still exists
+ * with the same signature. This file is essentially the 0.0.1 public API written
+ * out longhand. If a refactor renames or re-types any of it, this stops
+ * compiling, which is exactly the point. A Kotlin test would silently follow the
+ * rename.
+ */
+@RunWith(AndroidJUnit4.class)
+public class JavaApiCompatibilityTest {
+
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = ApplicationProvider.getApplicationContext();
+ }
+
+ // region the 0.0.1 API surface
+
+ @Test
+ public void everyMethodFrom_0_0_1_stillCompilesAndWorks() {
+ SegmentedProgressBar bar = new SegmentedProgressBar(context);
+
+ bar.setDivisions(10);
+ bar.setProgressBarColor(Color.RED);
+ bar.setDividerColor(Color.BLUE);
+ bar.setDividerWidth(4f);
+ bar.setDividerEnabled(false);
+ bar.setCornerRadius(6f);
+
+ List enabled = Arrays.asList(1, 4, 5, 8, 9);
+ bar.setEnabledDivisions(enabled);
+
+ assertThat(bar.getDivisions()).isEqualTo(10);
+ assertThat(bar.getProgressBarColor()).isEqualTo(Color.RED);
+ assertThat(bar.getDividerColor()).isEqualTo(Color.BLUE);
+ assertThat(bar.getDividerWidth()).isEqualTo(4f);
+ assertThat(bar.isDividerEnabled()).isFalse();
+ assertThat(bar.getCornerRadius()).isEqualTo(6f);
+ assertThat(bar.getEnabledDivisions()).containsExactly(1, 4, 5, 8, 9).inOrder();
+
+ bar.reset();
+ assertThat(bar.getEnabledDivisions()).isEmpty();
+ // reset() must not have disturbed the configuration.
+ assertThat(bar.getDivisions()).isEqualTo(10);
+ }
+
+ @Test
+ @SuppressWarnings("deprecation")
+ public void theDeprecatedSetBackgroundColorStillCompilesAndPaintsTheTrack() {
+ SegmentedProgressBar bar = new SegmentedProgressBar(context);
+
+ bar.setBackgroundColor(Color.MAGENTA);
+
+ assertThat(bar.getProgressBarBackgroundColor()).isEqualTo(Color.MAGENTA);
+ }
+
+ @Test
+ public void theSampleCodeFromThe_0_0_1_readmeStillWorks() {
+ // Lifted from the 0.0.1 demo activity verbatim.
+ SegmentedProgressBar bar = new SegmentedProgressBar(context);
+ bar.setDivisions(10);
+
+ Integer[] enabled = new Integer[] {1, 4, 5, 8, 9};
+ bar.setEnabledDivisions(Arrays.asList(enabled));
+
+ assertThat(bar.getCompletedSegmentCount()).isEqualTo(5);
+ }
+
+ // endregion
+
+ // region constructors
+
+ @Test
+ public void allFourViewConstructorsAreAvailableToJava() {
+ assertThat(new SegmentedProgressBar(context)).isNotNull();
+ assertThat(new SegmentedProgressBar(context, null)).isNotNull();
+ assertThat(new SegmentedProgressBar(context, null, 0)).isNotNull();
+ assertThat(new SegmentedProgressBar(context, null, 0, 0)).isNotNull();
+ }
+
+ // endregion
+
+ // region subclassing
+
+ /**
+ * The 0.0.1 class was a plain Java class and therefore subclassable. Kotlin
+ * classes are final by default, so this exists to keep the class
+ * {@code open}. If someone marks it final, this file stops compiling.
+ */
+ private static class CustomBar extends SegmentedProgressBar {
+
+ boolean drawn;
+
+ CustomBar(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void onDraw(android.graphics.Canvas canvas) {
+ super.onDraw(canvas);
+ drawn = true;
+ }
+ }
+
+ @Test
+ public void theViewCanStillBeSubclassedFromJava() {
+ CustomBar bar = new CustomBar(context);
+ bar.setDivisions(4);
+ bar.setEnabledDivisions(Arrays.asList(0, 1));
+
+ bar.measure(
+ android.view.View.MeasureSpec.makeMeasureSpec(200, android.view.View.MeasureSpec.EXACTLY),
+ android.view.View.MeasureSpec.makeMeasureSpec(20, android.view.View.MeasureSpec.EXACTLY));
+ bar.layout(0, 0, 200, 20);
+ bar.draw(new android.graphics.Canvas(
+ android.graphics.Bitmap.createBitmap(200, 20, android.graphics.Bitmap.Config.ARGB_8888)));
+
+ assertThat(bar.drawn).isTrue();
+ assertThat(bar.getCompletedSegmentCount()).isEqualTo(2);
+ }
+
+ // endregion
+
+ // region the 2.0.0 additions
+
+ @Test
+ public void theNewApiIsUsableFromJava() {
+ SegmentedProgressBar bar = new SegmentedProgressBar(context);
+ bar.setDivisions(8);
+
+ bar.setProgressBarBackgroundColor(Color.GREEN);
+ bar.setEnabledDivisions(Arrays.asList(0, 1, 2, 3, 4));
+
+ assertThat(bar.getProgressBarBackgroundColor()).isEqualTo(Color.GREEN);
+ assertThat(bar.getCompletedSegmentCount()).isEqualTo(5);
+
+ bar.enableDivision(7);
+ bar.disableDivision(0);
+
+ assertThat(bar.isDivisionEnabled(7)).isTrue();
+ assertThat(bar.isDivisionEnabled(0)).isFalse();
+ assertThat(bar.getEnabledDivisions()).containsExactly(1, 2, 3, 4, 7).inOrder();
+ }
+
+ @Test
+ public void theDefaultConstantsAreReadableFromJava() {
+ assertThat(SegmentedProgressBar.DEFAULT_DIVISIONS).isEqualTo(1);
+ assertThat(SegmentedProgressBar.DEFAULT_PROGRESS_COLOR).isEqualTo(0xFF5097E2);
+ assertThat(SegmentedProgressBar.DEFAULT_BACKGROUND_COLOR).isEqualTo(0xFFC1C1C1);
+ assertThat(SegmentedProgressBar.DEFAULT_DIVIDER_COLOR).isEqualTo(Color.WHITE);
+ assertThat(SegmentedProgressBar.DEFAULT_DIVIDER_WIDTH_PX).isEqualTo(1f);
+ assertThat(SegmentedProgressBar.DEFAULT_CORNER_RADIUS_PX).isEqualTo(2f);
+ }
+
+ // endregion
+
+ // region validation as seen from Java
+
+ @Test
+ public void invalidConfigurationThrowsForJavaCallersToo() {
+ SegmentedProgressBar bar = new SegmentedProgressBar(context);
+
+ try {
+ bar.setDivisions(0);
+ throw new AssertionError("expected setDivisions(0) to be rejected");
+ } catch (IllegalArgumentException expected) {
+ assertThat(expected).hasMessageThat().contains("divisions");
+ }
+
+ try {
+ bar.setDividerWidth(-1f);
+ throw new AssertionError("expected a negative divider width to be rejected");
+ } catch (IllegalArgumentException expected) {
+ assertThat(expected).hasMessageThat().contains("dividerWidth");
+ }
+ }
+
+ // endregion
+}
diff --git a/segmented/src/test/java/com/rachitgoyal/segmented/RecordingCanvas.kt b/segmented/src/test/java/com/rachitgoyal/segmented/RecordingCanvas.kt
new file mode 100644
index 0000000..d06812b
--- /dev/null
+++ b/segmented/src/test/java/com/rachitgoyal/segmented/RecordingCanvas.kt
@@ -0,0 +1,105 @@
+package com.rachitgoyal.segmented
+
+import android.graphics.Bitmap
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.graphics.Path
+import android.graphics.RectF
+import android.view.View
+
+/** A single recorded draw call, flattened to a bounding box and a colour. */
+internal data class DrawOp(
+ val left: Float,
+ val top: Float,
+ val right: Float,
+ val bottom: Float,
+ val color: Int,
+ val rounded: Boolean,
+) {
+ val width: Float get() = right - left
+ val height: Float get() = bottom - top
+}
+
+/**
+ * A [Canvas] that records the primitives drawn into it and still rasterises
+ * them, so the view under test is exercised exactly as it would be on a device.
+ *
+ * Recording the real draw calls rather than inspecting view state is what lets
+ * the drawing tests assert on geometry and colour, the things a user actually
+ * sees, instead of on implementation details.
+ */
+internal class RecordingCanvas(val bitmap: Bitmap) : Canvas(bitmap) {
+
+ val ops = mutableListOf()
+ val translations = mutableListOf>()
+
+ override fun translate(dx: Float, dy: Float) {
+ translations += dx to dy
+ super.translate(dx, dy)
+ }
+
+ override fun drawRect(left: Float, top: Float, right: Float, bottom: Float, paint: Paint) {
+ ops += DrawOp(left, top, right, bottom, paint.color, rounded = false)
+ super.drawRect(left, top, right, bottom, paint)
+ }
+
+ override fun drawPath(path: Path, paint: Paint) {
+ val bounds = RectF()
+ path.computeBounds(bounds, true)
+ ops += DrawOp(bounds.left, bounds.top, bounds.right, bounds.bottom, paint.color, true)
+ super.drawPath(path, paint)
+ }
+}
+
+/** Ops of exactly this colour, alpha included. */
+internal fun List.ofColor(color: Int): List = filter { it.color == color }
+
+/**
+ * Ops of this colour ignoring alpha.
+ *
+ * Needed wherever a fade is in flight: the paint's alpha is scaled during the
+ * transition, so an exact colour match would silently find nothing.
+ */
+internal fun List.ofRgb(color: Int): List =
+ filter { it.color and 0x00FFFFFF == color and 0x00FFFFFF }
+
+/** Renders [view] at the given size through a [RecordingCanvas]. */
+internal fun renderToRecordingCanvas(view: View, width: Int, height: Int): RecordingCanvas {
+ view.measure(
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY),
+ )
+ view.layout(0, 0, width, height)
+ val canvas = RecordingCanvas(
+ Bitmap.createBitmap(
+ width.coerceAtLeast(1),
+ height.coerceAtLeast(1),
+ Bitmap.Config.ARGB_8888,
+ ),
+ )
+ view.draw(canvas)
+ return canvas
+}
+
+/**
+ * Bounding box of a set of ops, as a single synthetic [DrawOp].
+ *
+ * The track is drawn cell by cell rather than as one span (so that gaps are
+ * genuinely empty), so assertions about "the track" as a whole work on the union
+ * of its cells.
+ */
+internal fun List.union(): DrawOp {
+ require(isNotEmpty()) { "no ops to union" }
+ return DrawOp(
+ left = minOf { it.left },
+ top = minOf { it.top },
+ right = maxOf { it.right },
+ bottom = maxOf { it.bottom },
+ color = first().color,
+ rounded = any { it.rounded },
+ )
+}
+
+/** Alpha channel of a recorded op's colour, as a 0..1 fraction. */
+internal val DrawOp.alphaFraction: Float get() = Color.alpha(color) / 255f
diff --git a/segmented/src/test/java/com/rachitgoyal/segmented/SegmentGeometryTest.kt b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentGeometryTest.kt
new file mode 100644
index 0000000..959ad29
--- /dev/null
+++ b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentGeometryTest.kt
@@ -0,0 +1,341 @@
+package com.rachitgoyal.segmented
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+/**
+ * Unit tests for the layout maths.
+ *
+ * These are deliberately free of Android and Robolectric so the geometry
+ * contract can be pinned down exhaustively and cheaply. The invariant that
+ * matters most is *tiling*: segments and dividers together must cover the bar
+ * exactly, with no gaps and no overlap, for every combination of inputs.
+ */
+class SegmentGeometryTest {
+
+ private val tolerance = 0.0001f
+
+ // region effectiveDividerWidth
+
+ @Test
+ fun `effective divider width is zero when dividers are disabled`() {
+ assertThat(
+ SegmentGeometry.effectiveDividerWidth(
+ width = 300f,
+ divisions = 5,
+ requested = 10f,
+ enabled = false,
+ ),
+ ).isEqualTo(0f)
+ }
+
+ @Test
+ fun `effective divider width is zero for a single division`() {
+ // With one division there are no interior boundaries to divide.
+ assertThat(
+ SegmentGeometry.effectiveDividerWidth(
+ width = 300f,
+ divisions = 1,
+ requested = 10f,
+ enabled = true,
+ ),
+ ).isEqualTo(0f)
+ }
+
+ @Test
+ fun `effective divider width is zero for a zero-width bar`() {
+ assertThat(
+ SegmentGeometry.effectiveDividerWidth(
+ width = 0f,
+ divisions = 5,
+ requested = 10f,
+ enabled = true,
+ ),
+ ).isEqualTo(0f)
+ }
+
+ @Test
+ fun `effective divider width is zero when nothing was requested`() {
+ assertThat(
+ SegmentGeometry.effectiveDividerWidth(
+ width = 300f,
+ divisions = 5,
+ requested = 0f,
+ enabled = true,
+ ),
+ ).isEqualTo(0f)
+ }
+
+ @Test
+ fun `effective divider width passes through a reasonable request`() {
+ assertThat(
+ SegmentGeometry.effectiveDividerWidth(
+ width = 300f,
+ divisions = 5,
+ requested = 8f,
+ enabled = true,
+ ),
+ ).isWithin(tolerance).of(8f)
+ }
+
+ @Test
+ fun `effective divider width clamps a request wider than one segment`() {
+ // 300 / 3 == 100, so a 500px divider collapses to 100px rather than
+ // driving segment widths negative.
+ assertThat(
+ SegmentGeometry.effectiveDividerWidth(
+ width = 300f,
+ divisions = 3,
+ requested = 500f,
+ enabled = true,
+ ),
+ ).isWithin(tolerance).of(100f)
+ }
+
+ // endregion
+
+ // region boundary
+
+ @Test
+ fun `boundaries are evenly spaced and span the full width`() {
+ val width = 300f
+ val divisions = 3
+
+ assertThat(SegmentGeometry.boundary(width, divisions, 0)).isWithin(tolerance).of(0f)
+ assertThat(SegmentGeometry.boundary(width, divisions, 1)).isWithin(tolerance).of(100f)
+ assertThat(SegmentGeometry.boundary(width, divisions, 2)).isWithin(tolerance).of(200f)
+ assertThat(SegmentGeometry.boundary(width, divisions, divisions)).isWithin(tolerance).of(width)
+ }
+
+ // endregion
+
+ // region segments
+
+ @Test
+ fun `a single division fills the whole bar`() {
+ assertThat(SegmentGeometry.segmentLeft(300f, 1, 0f, 0)).isWithin(tolerance).of(0f)
+ assertThat(SegmentGeometry.segmentRight(300f, 1, 0f, 0)).isWithin(tolerance).of(300f)
+ }
+
+ @Test
+ fun `segments are inset by half a divider on interior edges only`() {
+ val width = 300f
+ val divisions = 3
+ val divider = 10f
+
+ // First segment: flush against the left edge, inset on its right.
+ assertThat(SegmentGeometry.segmentLeft(width, divisions, divider, 0)).isWithin(tolerance).of(0f)
+ assertThat(SegmentGeometry.segmentRight(width, divisions, divider, 0)).isWithin(tolerance).of(95f)
+
+ // Middle segment: inset on both sides.
+ assertThat(SegmentGeometry.segmentLeft(width, divisions, divider, 1)).isWithin(tolerance).of(105f)
+ assertThat(SegmentGeometry.segmentRight(width, divisions, divider, 1)).isWithin(tolerance).of(195f)
+
+ // Last segment: inset on its left, flush against the right edge.
+ assertThat(SegmentGeometry.segmentLeft(width, divisions, divider, 2)).isWithin(tolerance).of(205f)
+ assertThat(SegmentGeometry.segmentRight(width, divisions, divider, 2)).isWithin(tolerance).of(width)
+ }
+
+ @Test
+ fun `segments tile the bar exactly when there is no divider`() {
+ val width = 250f
+ val divisions = 7
+
+ for (index in 0 until divisions) {
+ val left = SegmentGeometry.segmentLeft(width, divisions, 0f, index)
+ val right = SegmentGeometry.segmentRight(width, divisions, 0f, index)
+ // Each segment starts exactly where the previous one ended.
+ assertThat(left).isWithin(tolerance).of(SegmentGeometry.boundary(width, divisions, index))
+ assertThat(right).isWithin(tolerance).of(SegmentGeometry.boundary(width, divisions, index + 1))
+ }
+ }
+
+ @Test
+ fun `a divider clamped to a full segment collapses that segment to zero width`() {
+ // The pathological case: divider == segment width. Interior segments
+ // degenerate to zero width but must never go negative.
+ val width = 300f
+ val divisions = 3
+ val divider = SegmentGeometry.effectiveDividerWidth(width, divisions, 500f, enabled = true)
+
+ for (index in 0 until divisions) {
+ val left = SegmentGeometry.segmentLeft(width, divisions, divider, index)
+ val right = SegmentGeometry.segmentRight(width, divisions, divider, index)
+ assertThat(right - left).isAtLeast(0f)
+ }
+
+ assertThat(SegmentGeometry.segmentRight(width, divisions, divider, 1) -
+ SegmentGeometry.segmentLeft(width, divisions, divider, 1))
+ .isWithin(tolerance).of(0f)
+ }
+
+ // endregion
+
+ // region dividers
+
+ @Test
+ fun `dividers are centred on interior boundaries`() {
+ val width = 300f
+ val divisions = 3
+ val divider = 10f
+
+ assertThat(SegmentGeometry.dividerLeft(width, divisions, divider, 1)).isWithin(tolerance).of(95f)
+ assertThat(SegmentGeometry.dividerRight(width, divisions, divider, 1)).isWithin(tolerance).of(105f)
+ assertThat(SegmentGeometry.dividerLeft(width, divisions, divider, 2)).isWithin(tolerance).of(195f)
+ assertThat(SegmentGeometry.dividerRight(width, divisions, divider, 2)).isWithin(tolerance).of(205f)
+ }
+
+ @Test
+ fun `each divider abuts its neighbouring segments with no gap or overlap`() {
+ val width = 480f
+ val divisions = 6
+ val divider = 7f
+
+ for (index in 1 until divisions) {
+ // The segment to the left ends exactly where the divider begins...
+ assertThat(SegmentGeometry.segmentRight(width, divisions, divider, index - 1))
+ .isWithin(tolerance)
+ .of(SegmentGeometry.dividerLeft(width, divisions, divider, index))
+ // ...and the segment to the right begins exactly where it ends.
+ assertThat(SegmentGeometry.segmentLeft(width, divisions, divider, index))
+ .isWithin(tolerance)
+ .of(SegmentGeometry.dividerRight(width, divisions, divider, index))
+ }
+ }
+
+ // endregion
+
+ // region tiling invariant
+
+ @Test
+ fun `segments plus dividers always cover the bar exactly`() {
+ val widths = listOf(1f, 17f, 100f, 299.5f, 1080f, 4096f)
+ val divisionCounts = listOf(1, 2, 3, 5, 7, 30, 100)
+ val requestedDividers = listOf(0f, 0.5f, 1f, 6f, 40f, 10_000f)
+
+ for (width in widths) {
+ for (divisions in divisionCounts) {
+ for (requested in requestedDividers) {
+ val divider = SegmentGeometry.effectiveDividerWidth(
+ width = width,
+ divisions = divisions,
+ requested = requested,
+ enabled = true,
+ )
+
+ var covered = 0f
+ for (index in 0 until divisions) {
+ val segment = SegmentGeometry.segmentRight(width, divisions, divider, index) -
+ SegmentGeometry.segmentLeft(width, divisions, divider, index)
+ // Never inverted, for any input, see segmentRight's
+ // lower bound.
+ assertThat(segment).isAtLeast(0f)
+ covered += segment
+ }
+ for (index in 1 until divisions) {
+ covered += SegmentGeometry.dividerRight(width, divisions, divider, index) -
+ SegmentGeometry.dividerLeft(width, divisions, divider, index)
+ }
+
+ assertThat(covered)
+ .isWithin(width * 0.0005f + tolerance)
+ .of(width)
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `segments never escape the bounds of the bar`() {
+ val width = 333f
+ val divisions = 9
+ val divider = SegmentGeometry.effectiveDividerWidth(width, divisions, 12f, enabled = true)
+
+ for (index in 0 until divisions) {
+ assertThat(SegmentGeometry.segmentLeft(width, divisions, divider, index)).isAtLeast(0f)
+ assertThat(SegmentGeometry.segmentRight(width, divisions, divider, index)).isAtMost(width)
+ }
+ }
+
+ // endregion
+
+ // region mirror
+
+ @Test
+ fun `mirroring reflects about the centre of the bar`() {
+ assertThat(SegmentGeometry.mirror(300f, 0f)).isWithin(tolerance).of(300f)
+ assertThat(SegmentGeometry.mirror(300f, 300f)).isWithin(tolerance).of(0f)
+ assertThat(SegmentGeometry.mirror(300f, 150f)).isWithin(tolerance).of(150f)
+ assertThat(SegmentGeometry.mirror(300f, 100f)).isWithin(tolerance).of(200f)
+ }
+
+ @Test
+ fun `mirroring a span preserves its width`() {
+ val width = 300f
+ val divisions = 4
+ val divider = 8f
+
+ for (index in 0 until divisions) {
+ val left = SegmentGeometry.segmentLeft(width, divisions, divider, index)
+ val right = SegmentGeometry.segmentRight(width, divisions, divider, index)
+ val mirroredLeft = SegmentGeometry.mirror(width, right)
+ val mirroredRight = SegmentGeometry.mirror(width, left)
+
+ assertThat(mirroredRight - mirroredLeft).isWithin(tolerance).of(right - left)
+ assertThat(mirroredLeft).isAtLeast(-tolerance)
+ assertThat(mirroredRight).isAtMost(width + tolerance)
+ }
+ }
+
+ @Test
+ fun `mirroring segment zero puts it at the far end of the bar`() {
+ val width = 300f
+ val divisions = 3
+
+ val mirroredRight = SegmentGeometry.mirror(
+ width,
+ SegmentGeometry.segmentLeft(width, divisions, 0f, 0),
+ )
+ assertThat(mirroredRight).isWithin(tolerance).of(width)
+ }
+
+ @Test
+ fun `the set of divider positions is symmetric so rtl needs no mirroring`() {
+ val width = 300f
+ val divisions = 5
+ val divider = 6f
+
+ val positions = (1 until divisions).map {
+ SegmentGeometry.dividerLeft(width, divisions, divider, it)
+ }
+ val mirroredPositions = (1 until divisions).map {
+ SegmentGeometry.mirror(width, SegmentGeometry.dividerRight(width, divisions, divider, it))
+ }.sorted()
+
+ positions.forEachIndexed { i, position ->
+ assertThat(mirroredPositions[i]).isWithin(tolerance).of(position)
+ }
+ }
+
+ // endregion
+
+ // region clampCornerRadius
+
+ @Test
+ fun `corner radius is clamped to half the smaller dimension`() {
+ assertThat(SegmentGeometry.clampCornerRadius(100f, 300f, 20f)).isWithin(tolerance).of(10f)
+ assertThat(SegmentGeometry.clampCornerRadius(100f, 12f, 300f)).isWithin(tolerance).of(6f)
+ }
+
+ @Test
+ fun `a reasonable corner radius passes through untouched`() {
+ assertThat(SegmentGeometry.clampCornerRadius(4f, 300f, 20f)).isWithin(tolerance).of(4f)
+ }
+
+ @Test
+ fun `a negative corner radius clamps to zero`() {
+ assertThat(SegmentGeometry.clampCornerRadius(-8f, 300f, 20f)).isEqualTo(0f)
+ }
+
+ // endregion
+}
diff --git a/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarDrawingTest.kt b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarDrawingTest.kt
new file mode 100644
index 0000000..145fe32
--- /dev/null
+++ b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarDrawingTest.kt
@@ -0,0 +1,557 @@
+package com.rachitgoyal.segmented
+
+import android.app.Activity
+import android.content.Context
+import android.content.pm.ApplicationInfo
+import android.graphics.Color
+import android.view.View
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+
+/**
+ * Tests what the view actually puts on the canvas.
+ *
+ * Drawing is where every one of 0.0.1's visible defects lived, so rather than
+ * assert on internal state these tests record the real draw calls through a
+ * [RecordingCanvas] and assert on their geometry and colour. Expected
+ * coordinates are written out by hand rather than derived from
+ * [SegmentGeometry], so a mistake in the geometry cannot make a drawing test
+ * pass.
+ *
+ * The fixture throughout is a 300 x 30 bar with 3 divisions and a 10px divider,
+ * which lays out as:
+ * ```
+ * segment 0: 0 .. 95 divider 1: 95 .. 105
+ * segment 1: 105 .. 195 divider 2: 195 .. 205
+ * segment 2: 205 .. 300
+ * ```
+ */
+@RunWith(AndroidJUnit4::class)
+class SegmentedProgressBarDrawingTest {
+
+ private companion object {
+ const val WIDTH = 300
+ const val HEIGHT = 30
+ const val DIVISIONS = 3
+ const val DIVIDER_WIDTH = 10f
+
+ const val PROGRESS = Color.RED
+ const val TRACK = Color.GREEN
+ const val DIVIDER = Color.BLUE
+ }
+
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ }
+
+ // region harness
+
+ private fun newBar(
+ cornerRadius: Float = 0f,
+ configure: SegmentedProgressBar.() -> Unit = {},
+ ) = SegmentedProgressBar(context).apply {
+ divisions = DIVISIONS
+ dividerWidth = DIVIDER_WIDTH
+ progressBarColor = PROGRESS
+ progressBarBackgroundColor = TRACK
+ dividerColor = DIVIDER
+ this.cornerRadius = cornerRadius
+ configure()
+ }
+
+ private fun render(
+ bar: SegmentedProgressBar,
+ width: Int = WIDTH,
+ height: Int = HEIGHT,
+ ): RecordingCanvas = renderToRecordingCanvas(bar, width, height)
+
+ private fun assertSpan(op: DrawOp, left: Float, right: Float) {
+ assertThat(op.left).isWithin(0.01f).of(left)
+ assertThat(op.right).isWithin(0.01f).of(right)
+ assertThat(op.top).isWithin(0.01f).of(0f)
+ assertThat(op.bottom).isWithin(0.01f).of(HEIGHT.toFloat())
+ }
+
+ // endregion
+
+ // region the track
+
+ @Test
+ fun `the track is drawn across the full width even when nothing is lit`() {
+ val canvas = render(newBar())
+
+ // One cell per division rather than a single span: that is what makes a
+ // transparent divider a true gap instead of a hole showing the track.
+ val track = canvas.ops.ofColor(TRACK)
+ assertThat(track).hasSize(DIVISIONS)
+ assertSpan(track.union(), left = 0f, right = WIDTH.toFloat())
+ }
+
+ @Test
+ fun `the track leaves the divider space empty`() {
+ val canvas = render(newBar())
+
+ val cells = canvas.ops.ofColor(TRACK).sortedBy { it.left }
+ // Cells stop either side of each divider rather than running under it.
+ assertSpan(cells[0], left = 0f, right = 95f)
+ assertSpan(cells[1], left = 105f, right = 195f)
+ assertSpan(cells[2], left = 205f, right = 300f)
+ }
+
+ // endregion
+
+ // region dividers
+
+ @Test
+ fun `dividers are drawn even when no segment is lit`() {
+ // Regression: 0.0.1 nested the divider loop inside the loop over enabled
+ // segments, so an empty bar had no dividers at all.
+ val canvas = render(newBar())
+
+ val dividers = canvas.ops.ofColor(DIVIDER)
+ assertThat(dividers).hasSize(DIVISIONS - 1)
+ assertSpan(dividers[0], left = 95f, right = 105f)
+ assertSpan(dividers[1], left = 195f, right = 205f)
+ }
+
+ @Test
+ fun `each divider is drawn exactly once regardless of how many segments are lit`() {
+ // Regression: the same nesting bug in 0.0.1 redrew every divider once per
+ // lit segment, six divider rects here instead of two.
+ val canvas = render(newBar { enabledDivisions = listOf(0, 1, 2) })
+
+ assertThat(canvas.ops.ofColor(DIVIDER)).hasSize(DIVISIONS - 1)
+ }
+
+ @Test
+ fun `no dividers are drawn when they are disabled`() {
+ val canvas = render(newBar { isDividerEnabled = false })
+
+ assertThat(canvas.ops.ofColor(DIVIDER)).isEmpty()
+ }
+
+ @Test
+ fun `no dividers are drawn for a zero divider width`() {
+ val canvas = render(newBar { dividerWidth = 0f })
+
+ assertThat(canvas.ops.ofColor(DIVIDER)).isEmpty()
+ }
+
+ @Test
+ fun `no dividers are drawn for a single division`() {
+ val canvas = render(newBar { divisions = 1 })
+
+ assertThat(canvas.ops.ofColor(DIVIDER)).isEmpty()
+ }
+
+ @Test
+ fun `disabling dividers makes segments span their full cell`() {
+ val canvas = render(newBar { isDividerEnabled = false; enabledDivisions = listOf(1) })
+
+ val segments = canvas.ops.ofColor(PROGRESS)
+ assertThat(segments).hasSize(1)
+ assertSpan(segments.single(), left = 100f, right = 200f)
+ }
+
+ // endregion
+
+ // region segments
+
+ @Test
+ fun `a lit segment is drawn inset by half a divider on its interior edges`() {
+ val canvas = render(newBar { enabledDivisions = listOf(1) })
+
+ val segments = canvas.ops.ofColor(PROGRESS)
+ assertThat(segments).hasSize(1)
+ assertSpan(segments.single(), left = 105f, right = 195f)
+ }
+
+ @Test
+ fun `the first and last segments run flush to the ends of the bar`() {
+ val canvas = render(newBar { enabledDivisions = listOf(0, 2) })
+
+ val segments = canvas.ops.ofColor(PROGRESS)
+ assertThat(segments).hasSize(2)
+ assertSpan(segments[0], left = 0f, right = 95f)
+ assertSpan(segments[1], left = 205f, right = 300f)
+ }
+
+ @Test
+ fun `only lit segments are drawn`() {
+ val canvas = render(newBar { enabledDivisions = listOf(2) })
+
+ val segments = canvas.ops.ofColor(PROGRESS)
+ assertThat(segments).hasSize(1)
+ assertSpan(segments.single(), left = 205f, right = 300f)
+ }
+
+ @Test
+ fun `every lit segment is drawn exactly once`() {
+ val canvas = render(newBar { enabledDivisions = listOf(0, 1, 2) })
+
+ assertThat(canvas.ops.ofColor(PROGRESS)).hasSize(3)
+ }
+
+ @Test
+ fun `out of range indices are skipped without crashing`() {
+ val canvas = render(
+ newBar {
+ divisions = 3
+ enabledDivisions = listOf(0, 3, 4, 1000)
+ },
+ )
+
+ val segments = canvas.ops.ofColor(PROGRESS)
+ assertThat(segments).hasSize(1)
+ assertSpan(segments.single(), left = 0f, right = 95f)
+ }
+
+ @Test
+ fun `segments never overlap the dividers`() {
+ // The 0.0.1 geometry added the full divider width to a segment's left
+ // edge without removing it from the right, so each segment bled under
+ // the divider to its right.
+ val canvas = render(newBar { enabledDivisions = listOf(0, 1, 2) })
+
+ val spans = (canvas.ops.ofColor(PROGRESS) + canvas.ops.ofColor(DIVIDER))
+ .sortedBy { it.left }
+
+ for ((previous, next) in spans.zipWithNext()) {
+ assertThat(next.left).isAtLeast(previous.right - 0.01f)
+ }
+ }
+
+ @Test
+ fun `lit segments and dividers together cover the whole bar`() {
+ val canvas = render(newBar { enabledDivisions = listOf(0, 1, 2) })
+
+ val covered = (canvas.ops.ofColor(PROGRESS) + canvas.ops.ofColor(DIVIDER))
+ .sumOf { (it.right - it.left).toDouble() }
+
+ assertThat(covered).isWithin(0.05).of(WIDTH.toDouble())
+ }
+
+ // endregion
+
+ // region corner rounding
+
+ @Test
+ fun `with a corner radius only the end segments are drawn as rounded paths`() {
+ val canvas = render(newBar(cornerRadius = 8f) { enabledDivisions = listOf(0, 1, 2) })
+
+ val segments = canvas.ops.ofColor(PROGRESS).sortedBy { it.left }
+ assertThat(segments).hasSize(3)
+ assertThat(segments[0].rounded).isTrue() // touches the left end
+ assertThat(segments[1].rounded).isFalse() // interior, square on both sides
+ assertThat(segments[2].rounded).isTrue() // touches the right end
+ }
+
+ @Test
+ fun `the track rounds only the ends of the bar by default`() {
+ val canvas = render(newBar(cornerRadius = 8f))
+
+ val cells = canvas.ops.ofColor(TRACK).sortedBy { it.left }
+ assertThat(cells.map { it.rounded }).containsExactly(true, false, true).inOrder()
+ }
+
+ @Test
+ fun `a zero corner radius draws everything as plain rectangles`() {
+ val canvas = render(newBar(cornerRadius = 0f) { enabledDivisions = listOf(0, 1, 2) })
+
+ assertThat(canvas.ops.map { it.rounded }).doesNotContain(true)
+ }
+
+ @Test
+ fun `rounded end segments still occupy their exact span`() {
+ // Rounding must change the corners, not the extent.
+ val canvas = render(newBar(cornerRadius = 8f) { enabledDivisions = listOf(0, 2) })
+
+ val segments = canvas.ops.ofColor(PROGRESS).sortedBy { it.left }
+ assertSpan(segments[0], left = 0f, right = 95f)
+ assertSpan(segments[1], left = 205f, right = 300f)
+ }
+
+ @Test
+ fun `an oversized corner radius is clamped to half the height`() {
+ // radius 500 on a 30px-tall bar must clamp to 15, leaving the span intact.
+ val canvas = render(newBar(cornerRadius = 500f) { enabledDivisions = listOf(0) })
+
+ val segment = canvas.ops.ofColor(PROGRESS).single()
+ assertSpan(segment, left = 0f, right = 95f)
+ }
+
+ // endregion
+
+ // region divider clamping
+
+ @Test
+ fun `a divider wider than a segment is clamped instead of inverting segments`() {
+ val canvas = render(
+ newBar {
+ divisions = 3
+ dividerWidth = 5000f
+ enabledDivisions = listOf(0, 1, 2)
+ },
+ )
+
+ // Clamped to width / divisions == 100, so the two end segments are 50px
+ // wide and the middle one collapses to nothing and is skipped.
+ val segments = canvas.ops.ofColor(PROGRESS).sortedBy { it.left }
+ assertThat(segments).hasSize(2)
+ assertSpan(segments[0], left = 0f, right = 50f)
+ assertSpan(segments[1], left = 250f, right = 300f)
+
+ for (op in canvas.ops) {
+ assertThat(op.right).isAtLeast(op.left)
+ }
+ }
+
+ // endregion
+
+ // region padding
+
+ @Test
+ fun `the track is inset by the view's padding`() {
+ val bar = newBar { setPadding(20, 4, 10, 6) }
+
+ val canvas = render(bar)
+
+ // The canvas is translated by the top-left padding...
+ assertThat(canvas.translations).contains(20f to 4f)
+ // ...and the track spans the remaining content width, not the view width.
+ val track = canvas.ops.ofColor(TRACK).union()
+ assertThat(track.left).isWithin(0.01f).of(0f)
+ assertThat(track.right).isWithin(0.01f).of((WIDTH - 20 - 10).toFloat())
+ assertThat(track.bottom).isWithin(0.01f).of((HEIGHT - 4 - 6).toFloat())
+ }
+
+ @Test
+ fun `nothing is drawn when padding leaves no content box`() {
+ val bar = newBar { setPadding(WIDTH, HEIGHT, WIDTH, HEIGHT) }
+
+ val canvas = render(bar)
+
+ assertThat(canvas.ops).isEmpty()
+ }
+
+ // endregion
+
+ // region layout direction
+
+ /**
+ * Builds a bar whose layout direction genuinely resolves to right-to-left.
+ *
+ * Two things are needed, and both are easy to get wrong in a way that leaves
+ * the test quietly asserting left-to-right behaviour:
+ *
+ * 1. `View.resolveLayoutDirection` short-circuits unless the *application*
+ * declares `android:supportsRtl="true"`. That is how the platform gates
+ * RTL for every view, but a library module's synthetic test manifest
+ * declares nothing, so the flag has to be set here by hand.
+ * 2. `layoutDirection` is resolved inside the setter, so the flag must
+ * already be in place before the assignment happens.
+ *
+ * The precondition at the end makes a regression in either of those a hard
+ * failure rather than a silently weakened test.
+ */
+ private fun attachedRtlBar(configure: SegmentedProgressBar.() -> Unit): SegmentedProgressBar {
+ val application = ApplicationProvider.getApplicationContext()
+ application.applicationInfo.flags =
+ application.applicationInfo.flags or ApplicationInfo.FLAG_SUPPORTS_RTL
+
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val bar = SegmentedProgressBar(activity).apply {
+ divisions = DIVISIONS
+ dividerWidth = DIVIDER_WIDTH
+ progressBarColor = PROGRESS
+ progressBarBackgroundColor = TRACK
+ dividerColor = DIVIDER
+ cornerRadius = 0f
+ layoutDirection = View.LAYOUT_DIRECTION_RTL
+ configure()
+ }
+ activity.setContentView(bar)
+ check(bar.layoutDirection == View.LAYOUT_DIRECTION_RTL) {
+ "layout direction did not resolve to RTL, so this test would have " +
+ "asserted left-to-right behaviour instead"
+ }
+ return bar
+ }
+
+ @Test
+ fun `under rtl segment zero is drawn at the right hand end`() {
+ val bar = attachedRtlBar { enabledDivisions = listOf(0) }
+
+ val canvas = render(bar)
+
+ // The mirror image of the LTR span 0..95.
+ assertSpan(canvas.ops.ofColor(PROGRESS).single(), left = 205f, right = 300f)
+ }
+
+ @Test
+ fun `under rtl the last segment is drawn at the left hand end`() {
+ val bar = attachedRtlBar { enabledDivisions = listOf(2) }
+
+ val canvas = render(bar)
+
+ assertSpan(canvas.ops.ofColor(PROGRESS).single(), left = 0f, right = 95f)
+ }
+
+ @Test
+ fun `under rtl the middle segment is unmoved`() {
+ val bar = attachedRtlBar { enabledDivisions = listOf(1) }
+
+ val canvas = render(bar)
+
+ assertSpan(canvas.ops.ofColor(PROGRESS).single(), left = 105f, right = 195f)
+ }
+
+ @Test
+ fun `under rtl the divider positions are unchanged`() {
+ val bar = attachedRtlBar {}
+
+ val canvas = render(bar)
+
+ val dividers = canvas.ops.ofColor(DIVIDER).sortedBy { it.left }
+ assertThat(dividers).hasSize(DIVISIONS - 1)
+ assertSpan(dividers[0], left = 95f, right = 105f)
+ assertSpan(dividers[1], left = 195f, right = 205f)
+ }
+
+ @Test
+ fun `under rtl the rounded end follows segment zero to the right`() {
+ val bar = attachedRtlBar {
+ cornerRadius = 8f
+ enabledDivisions = listOf(0, 1)
+ }
+
+ val canvas = render(bar)
+
+ val segments = canvas.ops.ofColor(PROGRESS).sortedBy { it.left }
+ assertThat(segments).hasSize(2)
+ // The interior segment stays square; segment 0, now at the right end,
+ // is the one that carries a rounded corner.
+ assertThat(segments[0].rounded).isFalse()
+ assertThat(segments[1].rounded).isTrue()
+ assertSpan(segments[1], left = 205f, right = 300f)
+ }
+
+ @Test
+ fun `under rtl divisionAt maps a touch to the segment actually drawn there`() {
+ // The reason divisionAt lives on the view: under RTL the leftmost pixel
+ // belongs to the LAST segment. A caller doing `x / width * divisions`
+ // would toggle the mirror image of what the user touched.
+ val bar = attachedRtlBar {}
+ render(bar)
+
+ assertThat(bar.divisionAt(10f)).isEqualTo(DIVISIONS - 1)
+ assertThat(bar.divisionAt((WIDTH - 10).toFloat())).isEqualTo(0)
+ }
+
+ @Test
+ fun `under rtl a tapped segment is the one that lights up`() {
+ val bar = attachedRtlBar {}
+ render(bar)
+
+ // Tap near the right-hand end, which under RTL is segment 0.
+ val index = bar.divisionAt(290f)
+ bar.toggleDivision(index)
+ val canvas = render(bar)
+
+ assertThat(index).isEqualTo(0)
+ // And it is drawn at the right-hand end, where the finger was.
+ assertSpan(canvas.ops.ofColor(PROGRESS).single(), left = 205f, right = 300f)
+ }
+
+ @Test
+ fun `under rtl a tap lights the segment the finger was actually on`() {
+ // The end-to-end version of the trap: touch the right-hand end, which in
+ // RTL is segment 0, and check that the paint lands under the finger
+ // rather than at the mirrored position.
+ val bar = attachedRtlBar {
+ setOnDivisionClickListener { view, index -> view.toggleDivision(index) }
+ }
+ render(bar)
+
+ val now = android.os.SystemClock.uptimeMillis()
+ val touchX = 290f
+ bar.dispatchTouchEvent(
+ android.view.MotionEvent.obtain(now, now, android.view.MotionEvent.ACTION_DOWN, touchX, 5f, 0),
+ )
+ bar.dispatchTouchEvent(
+ android.view.MotionEvent.obtain(now, now, android.view.MotionEvent.ACTION_UP, touchX, 5f, 0),
+ )
+ org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle()
+ val canvas = render(bar)
+
+ assertThat(bar.enabledDivisions).containsExactly(0)
+ assertSpan(canvas.ops.ofColor(PROGRESS).single(), left = 205f, right = 300f)
+ }
+
+ @Test
+ fun `a non-contiguous set draws exactly the requested segments`() {
+ // The library's headline case, asserted at the pixel level: five of ten
+ // lit, with gaps, and nothing drawn in the gaps.
+ val bar = newBar {
+ divisions = 10
+ dividerWidth = 0f
+ enabledDivisions = listOf(1, 2, 5, 6, 9)
+ }
+
+ val canvas = render(bar, width = 1000, height = HEIGHT)
+
+ val segments = canvas.ops.ofColor(PROGRESS).sortedBy { it.left }
+ assertThat(segments).hasSize(5)
+ // 1000 / 10 == 100px per segment.
+ val expected = listOf(100f to 200f, 200f to 300f, 500f to 600f, 600f to 700f, 900f to 1000f)
+ expected.forEachIndexed { i, (left, right) ->
+ assertThat(segments[i].left).isWithin(0.01f).of(left)
+ assertThat(segments[i].right).isWithin(0.01f).of(right)
+ }
+ }
+
+ // endregion
+
+ // region degenerate sizes
+
+ @Test
+ fun `a zero width bar draws nothing and does not crash`() {
+ val canvas = render(newBar(), width = 0, height = HEIGHT)
+
+ assertThat(canvas.ops).isEmpty()
+ }
+
+ @Test
+ fun `a one pixel bar with many divisions does not crash`() {
+ val canvas = render(
+ newBar {
+ divisions = 50
+ dividerWidth = 4f
+ enabledDivisions = (0 until 50).toList()
+ },
+ width = 1,
+ height = 1,
+ )
+
+ for (op in canvas.ops) {
+ assertThat(op.right).isAtLeast(op.left)
+ }
+ }
+
+ @Test
+ fun `a bar with many divisions draws every divider`() {
+ val canvas = render(newBar { divisions = 40; dividerWidth = 1f })
+
+ assertThat(canvas.ops.ofColor(DIVIDER)).hasSize(39)
+ }
+
+ // endregion
+}
diff --git a/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarShadowTest.kt b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarShadowTest.kt
new file mode 100644
index 0000000..f8be168
--- /dev/null
+++ b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarShadowTest.kt
@@ -0,0 +1,308 @@
+package com.rachitgoyal.segmented
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.Color
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.GraphicsMode
+
+/**
+ * Tests for how the drop shadow is drawn, at the pixel level.
+ *
+ * Pixels rather than recorded draw calls, unusually for this suite, because every
+ * bug these cover was about how shadows *accumulate*: two of them landing on the
+ * same spot, or one landing on top of something already drawn. Neither shows up in
+ * a list of draw calls, and both were plainly visible on a screen.
+ *
+ * That needs [GraphicsMode.Mode.NATIVE]. Robolectric's default graphics are stubs:
+ * `getPixel` returns whatever a shadow implementation chose to record, which here
+ * was a handful of cell boundaries and no blur at all, so every assertion below
+ * passed or failed for reasons unrelated to the code. Native mode is scoped to this
+ * class rather than set for the whole module, so the other tests keep running
+ * against the faster stubs they were written against.
+ *
+ * The fixture is deliberately *not* pixel aligned, because anti-aliased edges are
+ * where a shadow shows up in places it should not: 158px of content over 4
+ * divisions puts every cell boundary on a half pixel.
+ * ```
+ * padding 20 | cell 0 | cell 1 | cell 2 | cell 3 | padding 20
+ * | 20..59.5 | ..99 | ..138.5 | ..178 |
+ * (lit)
+ * ```
+ */
+@RunWith(AndroidJUnit4::class)
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class SegmentedProgressBarShadowTest {
+
+ private companion object {
+ const val WIDTH = 198
+ const val HEIGHT = 61
+ const val PADDING = 20
+ const val DIVISIONS = 4
+
+ const val PROGRESS = Color.RED
+ const val TRACK = Color.GREEN
+
+ /** Centre of the lit cell, and of an unlit one. */
+ const val LIT_X = 79
+ const val UNLIT_X = 158
+
+ /** Cell boundaries land on 59.5, 99 and 138.5. */
+ const val GAP_X = 99
+ const val FIRST_GAP_X = 59
+ const val FIRST_CELL_X = 40
+
+ /** The row just above the bar: outside it, well inside the blur. */
+ const val ABOVE_BAR_Y = PADDING - 2
+
+ /** A row through the middle of the bar itself. */
+ const val MID_BAR_Y = HEIGHT / 2
+
+ const val BLUR = 8f
+
+ /**
+ * Slack for comparing one part of the blur against another.
+ *
+ * Two cells at different places along the bar have slightly different
+ * neighbours contributing to the blur above them, so their shadows are
+ * equal only to within a shade. Anything the tests here care about is off
+ * by tens of levels, not by one.
+ */
+ const val SHADE = 2
+ }
+
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ }
+
+ private fun newBar(configure: SegmentedProgressBar.() -> Unit = {}) =
+ SegmentedProgressBar(context).apply {
+ divisions = DIVISIONS
+ dividerWidth = 0f
+ cornerRadius = 7f
+ progressBarColor = PROGRESS
+ progressBarBackgroundColor = TRACK
+ setPadding(PADDING, PADDING, PADDING, PADDING)
+ enabledDivisions = listOf(1)
+ // Opaque black, so that outside the bar a pixel's alpha *is* how much
+ // shadow landed on it, with no compositing to reason about.
+ shadowColor = Color.BLACK
+ shadowRadius = BLUR
+ configure()
+ }
+
+ private fun render(bar: SegmentedProgressBar): Bitmap =
+ renderToRecordingCanvas(bar, WIDTH, HEIGHT).bitmap
+
+ /** How much shadow reached ([x], [y]). Only meaningful outside the bar. */
+ private fun shadowAt(bar: SegmentedProgressBar, x: Int, y: Int): Int =
+ Color.alpha(render(bar).getPixel(x, y))
+
+ @Test
+ fun `a lit segment casts no more shadow than an unlit one`() {
+ // Regression: the track cell and the lit segment on top of it each cast
+ // their own shadow, and since the two coincide, every lit segment came out
+ // twice as dark as its neighbours.
+ val bar = newBar()
+
+ val aboveLit = shadowAt(bar, LIT_X, ABOVE_BAR_Y)
+ val aboveUnlit = shadowAt(bar, UNLIT_X, ABOVE_BAR_Y)
+
+ assertThat(aboveLit).isGreaterThan(0)
+ assertThat(aboveLit).isWithin(SHADE).of(aboveUnlit)
+ }
+
+ @Test
+ fun `the shadow never darkens the bar itself`() {
+ // Locks in the pass order: every shadow is drawn before any fill. Drawn
+ // cell by cell instead, as the first implementation did, each cell's
+ // shadow landed on the neighbour that had already been painted and drew a
+ // dark line down every shared edge.
+ assertPaintedPixelsUnchangedByShadow { }
+ }
+
+ @Test
+ fun `the shadow never darkens a run of adjacent segments`() {
+ // The same, at the one place a seam is most obvious: inside a run, where
+ // EACH_RUN means there is no divider to hide it.
+ assertPaintedPixelsUnchangedByShadow {
+ enabledDivisions = listOf(1, 2)
+ cornerMode = CornerMode.EACH_RUN
+ }
+ }
+
+ /**
+ * Asserts that turning the shadow on leaves every pixel the bar paints alone.
+ *
+ * Restricted to pixels the bar covers completely, because a partly covered edge
+ * pixel *should* pick up some shadow: whatever is behind it shows through in
+ * proportion, and behind it is the blur.
+ */
+ private fun assertPaintedPixelsUnchangedByShadow(
+ configure: SegmentedProgressBar.() -> Unit,
+ ) {
+ val plain = render(newBar { configure(); shadowRadius = 0f })
+ val shadowed = render(newBar(configure))
+ var compared = 0
+
+ for (x in PADDING until WIDTH - PADDING) {
+ for (y in PADDING until HEIGHT - PADDING) {
+ if (Color.alpha(plain.getPixel(x, y)) != 255) continue
+ compared++
+ assertThat(shadowed.getPixel(x, y)).isEqualTo(plain.getPixel(x, y))
+ }
+ }
+ // Guards the assertion itself: an all-transparent render would pass the
+ // loop above without comparing anything.
+ assertThat(compared).isGreaterThan(1000)
+ }
+
+ @Test
+ fun `a translucent segment is not muddied by the shadow beneath it`() {
+ // The shadow is a filled shape, so without clipping the silhouette out of
+ // it, its own footprint sits at full shadow alpha directly under the
+ // segment. Anything you can see through, a fading segment or simply a
+ // translucent colour, then looks dirty.
+ val translucent: SegmentedProgressBar.() -> Unit = {
+ progressBarColor = 0x80FF0000.toInt()
+ progressBarBackgroundColor = Color.TRANSPARENT
+ }
+ val plain = render(newBar { translucent(); shadowRadius = 0f })
+ val shadowed = render(newBar(translucent))
+
+ // Well inside the lit cell, clear of its anti-aliased edges.
+ for (x in LIT_X - 8..LIT_X + 8) {
+ assertThat(shadowed.getPixel(x, MID_BAR_Y)).isEqualTo(plain.getPixel(x, MID_BAR_Y))
+ }
+ }
+
+ // region gaps
+
+ /**
+ * The same bar with a real gap between its cells.
+ *
+ * The gap is transparent, the default, so anything visible inside it came from
+ * the shadow. Cell boundaries sit at 59.5, 99 and 138.5.
+ */
+ private fun newGappedBar(configure: SegmentedProgressBar.() -> Unit = {}) = newBar {
+ dividerWidth = 8f
+ dividerColor = Color.TRANSPARENT
+ configure()
+ }
+
+ @Test
+ fun `a gap grows no dark tick above the bar`() {
+ // Regression: with one blur per cell, the two neighbouring blurs met inside
+ // the gap and added up, which read as a dark tick poking out above and
+ // below the bar at every gap. One blur of one shape cannot do that.
+ val bar = newGappedBar()
+ val image = render(bar)
+
+ val aboveGap = Color.alpha(image.getPixel(GAP_X, ABOVE_BAR_Y))
+ val aboveCell = Color.alpha(image.getPixel(LIT_X, ABOVE_BAR_Y))
+
+ assertThat(aboveCell).isGreaterThan(0)
+ assertThat(aboveGap).isWithin(SHADE).of(aboveCell)
+ }
+
+ @Test
+ fun `no shadow is drawn inside a gap`() {
+ // Otherwise a narrow gap fills in with blur from both sides and becomes
+ // exactly the divider line that leaving it transparent asked to be rid of.
+ val plain = render(newGappedBar { shadowRadius = 0f })
+ val shadowed = render(newGappedBar())
+
+ for (x in GAP_X - 1..GAP_X + 1) {
+ assertThat(shadowed.getPixel(x, MID_BAR_Y)).isEqualTo(plain.getPixel(x, MID_BAR_Y))
+ }
+ }
+
+ @Test
+ fun `a gap between two cells the target skips is not bridged`() {
+ // With only the lit cells casting, the shadow has to stop at the edge of
+ // the run rather than running on behind the unlit ones.
+ val bar = newGappedBar {
+ enabledDivisions = listOf(0)
+ shadowTarget = ShadowTarget.ON_SEGMENTS
+ }
+ val image = render(bar)
+
+ // The gap after cell 0 borders an unlit cell, so nothing bridges it.
+ assertThat(Color.alpha(image.getPixel(FIRST_GAP_X, ABOVE_BAR_Y)))
+ .isLessThan(Color.alpha(image.getPixel(FIRST_CELL_X, ABOVE_BAR_Y)))
+ }
+
+ // endregion
+
+ @Test
+ fun `on segments shadows only the lit cells`() {
+ val bar = newBar { shadowTarget = ShadowTarget.ON_SEGMENTS }
+
+ assertThat(shadowAt(bar, LIT_X, ABOVE_BAR_Y)).isGreaterThan(0)
+ assertThat(shadowAt(bar, UNLIT_X, ABOVE_BAR_Y)).isEqualTo(0)
+ }
+
+ @Test
+ fun `off segments shadows only the unlit cells`() {
+ val bar = newBar { shadowTarget = ShadowTarget.OFF_SEGMENTS }
+
+ assertThat(shadowAt(bar, LIT_X, ABOVE_BAR_Y)).isEqualTo(0)
+ assertThat(shadowAt(bar, UNLIT_X, ABOVE_BAR_Y)).isGreaterThan(0)
+ }
+
+ @Test
+ fun `with all, the shadow is the same whichever segments are lit`() {
+ // The silhouette is the same either way, so the shadow has to be too.
+ val someLit = render(newBar { enabledDivisions = listOf(0, 2) })
+ val noneLit = render(newBar { enabledDivisions = emptyList() })
+
+ for (x in 0 until WIDTH) {
+ assertThat(Color.alpha(someLit.getPixel(x, ABOVE_BAR_Y)))
+ .isWithin(SHADE).of(Color.alpha(noneLit.getPixel(x, ABOVE_BAR_Y)))
+ }
+ }
+
+ @Test
+ fun `an invisible track casts no shadow of its own`() {
+ // Nothing is drawn where the off colour is transparent, so nothing there
+ // may cast a shadow either.
+ val bar = newBar { progressBarBackgroundColor = Color.TRANSPARENT }
+
+ assertThat(shadowAt(bar, UNLIT_X, ABOVE_BAR_Y)).isEqualTo(0)
+ assertThat(shadowAt(bar, LIT_X, ABOVE_BAR_Y)).isGreaterThan(0)
+ }
+
+ @Test
+ fun `the shadow fades outwards from the edge of the bar`() {
+ val bar = newBar()
+
+ val strengths = listOf(PADDING - 1, PADDING - 4, PADDING - 7, PADDING - 10)
+ .map { y -> shadowAt(bar, LIT_X, y) }
+
+ assertThat(strengths.first()).isGreaterThan(0)
+ strengths.zipWithNext { nearer, further ->
+ assertThat(nearer).isGreaterThan(further)
+ }
+ assertThat(shadowAt(bar, LIT_X, 0)).isEqualTo(0)
+ }
+
+ @Test
+ fun `the offset moves the shadow without moving the bar`() {
+ val centred = newBar()
+ val pushedDown = newBar { shadowDy = 6f }
+
+ assertThat(shadowAt(pushedDown, LIT_X, ABOVE_BAR_Y))
+ .isLessThan(shadowAt(centred, LIT_X, ABOVE_BAR_Y))
+ assertThat(shadowAt(pushedDown, LIT_X, HEIGHT - PADDING + 1))
+ .isGreaterThan(shadowAt(centred, LIT_X, HEIGHT - PADDING + 1))
+ assertThat(render(pushedDown).getPixel(LIT_X, MID_BAR_Y)).isEqualTo(PROGRESS)
+ }
+}
diff --git a/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarTest.kt b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarTest.kt
new file mode 100644
index 0000000..3545ca3
--- /dev/null
+++ b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarTest.kt
@@ -0,0 +1,1127 @@
+package com.rachitgoyal.segmented
+
+import android.app.Activity
+import android.content.Context
+import android.graphics.Color
+import android.os.Looper
+import android.os.Parcel
+import android.os.Parcelable
+import android.os.SystemClock
+import android.util.SparseArray
+import android.view.AbsSavedState
+import android.view.MotionEvent
+import android.view.ViewGroup
+import android.view.View
+import android.view.View.MeasureSpec
+import android.view.accessibility.AccessibilityNodeInfo
+import android.widget.FrameLayout
+import android.widget.ProgressBar
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.Shadows.shadowOf
+
+/**
+ * Behavioural tests for the view's public contract: attribute parsing,
+ * validation, progress bookkeeping, measurement, instance state and
+ * accessibility.
+ *
+ * Several tests here are regressions for defects that shipped in 0.0.1 and are
+ * labelled as such, so that a future refactor cannot quietly reintroduce them.
+ */
+@RunWith(AndroidJUnit4::class)
+class SegmentedProgressBarTest {
+
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ }
+
+ private fun newBar() = SegmentedProgressBar(context)
+
+ /**
+ * Builds an [android.util.AttributeSet] for the given attributes.
+ *
+ * [Robolectric.buildAttributeSet] is deprecated upstream but remains the
+ * only way to synthesise an AttributeSet from a library module, which has no
+ * layout resources of its own to inflate. Inflation from a real layout file
+ * is covered end to end by the demo app's `InflationTest`.
+ */
+ @Suppress("DEPRECATION")
+ private fun attributesOf(vararg attributes: Pair) =
+ Robolectric.buildAttributeSet()
+ .apply { attributes.forEach { (id, value) -> addAttribute(id, value) } }
+ .build()
+
+ /**
+ * Builds a bar attached to a real activity.
+ *
+ * Accessibility assertions need this: `View.onInitializeAccessibilityNodeInfo`
+ * delegates to an internal method that bails out before copying properties
+ * such as `contentDescription` while the view has no attach info, so a
+ * detached view yields a node info that is misleadingly empty.
+ */
+ private fun attachedBar(configure: SegmentedProgressBar.() -> Unit): SegmentedProgressBar {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val bar = SegmentedProgressBar(activity).apply(configure)
+ activity.setContentView(bar)
+ return bar
+ }
+
+ /** Populates a node info the way an accessibility service would. */
+ private fun accessibilityNodeOf(bar: SegmentedProgressBar): AccessibilityNodeInfo =
+ checkNotNull(bar.createAccessibilityNodeInfo()) { "view produced no node info" }
+
+ /** Lays the bar out at a fixed size so that draw-time geometry is defined. */
+ private fun SegmentedProgressBar.layoutAt(width: Int, height: Int) = apply {
+ measure(
+ MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY),
+ )
+ layout(0, 0, width, height)
+ }
+
+ // region defaults
+
+ @Test
+ fun `a programmatically constructed bar uses the documented defaults`() {
+ val bar = newBar()
+
+ assertThat(bar.divisions).isEqualTo(SegmentedProgressBar.DEFAULT_DIVISIONS)
+ assertThat(bar.divisions).isEqualTo(1)
+ assertThat(bar.enabledDivisions).isEmpty()
+ assertThat(bar.isDividerEnabled).isTrue()
+ assertThat(bar.dividerWidth).isEqualTo(SegmentedProgressBar.DEFAULT_DIVIDER_WIDTH_PX)
+ assertThat(bar.cornerRadius).isEqualTo(SegmentedProgressBar.DEFAULT_CORNER_RADIUS_PX)
+ assertThat(bar.progressBarColor).isEqualTo(SegmentedProgressBar.DEFAULT_PROGRESS_COLOR)
+ assertThat(bar.progressBarBackgroundColor).isEqualTo(SegmentedProgressBar.DEFAULT_BACKGROUND_COLOR)
+ assertThat(bar.dividerColor).isEqualTo(SegmentedProgressBar.DEFAULT_DIVIDER_COLOR)
+ assertThat(bar.completedSegmentCount).isEqualTo(0)
+ }
+
+ @Test
+ fun `default colours keep the values that 0_0_1 shipped`() {
+ // Existing users upgrading must not see their bars change colour.
+ assertThat(SegmentedProgressBar.DEFAULT_PROGRESS_COLOR).isEqualTo(Color.parseColor("#5097e2"))
+ assertThat(SegmentedProgressBar.DEFAULT_BACKGROUND_COLOR).isEqualTo(Color.parseColor("#c1c1c1"))
+ assertThat(SegmentedProgressBar.DEFAULT_DIVIDER_COLOR).isEqualTo(Color.WHITE)
+ }
+
+ // endregion
+
+ // region attribute parsing
+
+ @Test
+ fun `every xml attribute is parsed`() {
+ val attrs = attributesOf(
+ R.attr.divisions to "7",
+ R.attr.progressBarColor to "#ff0000",
+ R.attr.progressBarBackgroundColor to "#00ff00",
+ R.attr.dividerColor to "#0000ff",
+ R.attr.dividerWidth to "3dp",
+ R.attr.isDividerEnabled to "false",
+ R.attr.cornerRadius to "5dp",
+ )
+
+ val bar = SegmentedProgressBar(context, attrs)
+ val density = context.resources.displayMetrics.density
+
+ assertThat(bar.divisions).isEqualTo(7)
+ assertThat(bar.progressBarColor).isEqualTo(Color.RED)
+ assertThat(bar.progressBarBackgroundColor).isEqualTo(Color.GREEN)
+ assertThat(bar.dividerColor).isEqualTo(Color.BLUE)
+ assertThat(bar.dividerWidth).isWithin(1f).of(3f * density)
+ assertThat(bar.isDividerEnabled).isFalse()
+ assertThat(bar.cornerRadius).isWithin(1f).of(5f * density)
+ }
+
+ @Test
+ fun `omitted xml attributes fall back to the defaults`() {
+ val attrs = attributesOf(R.attr.divisions to "4")
+
+ val bar = SegmentedProgressBar(context, attrs)
+
+ assertThat(bar.divisions).isEqualTo(4)
+ assertThat(bar.progressBarColor).isEqualTo(SegmentedProgressBar.DEFAULT_PROGRESS_COLOR)
+ assertThat(bar.dividerWidth).isEqualTo(SegmentedProgressBar.DEFAULT_DIVIDER_WIDTH_PX)
+ assertThat(bar.cornerRadius).isEqualTo(SegmentedProgressBar.DEFAULT_CORNER_RADIUS_PX)
+ }
+
+ @Test
+ fun `an invalid divisions attribute fails loudly at inflation`() {
+ val attrs = attributesOf(R.attr.divisions to "0")
+
+ val error = runCatching { SegmentedProgressBar(context, attrs) }.exceptionOrNull()
+
+ assertThat(error).isInstanceOf(IllegalArgumentException::class.java)
+ assertThat(error).hasMessageThat().contains("divisions")
+ }
+
+ @Test
+ fun `an android background colour does not crash and does not repaint the track`() {
+ // Regression guard: setBackgroundColor is overridden with non-standard
+ // semantics, so it must survive being reached during inflation.
+ val attrs = attributesOf(android.R.attr.background to "#ff00ff")
+
+ val bar = SegmentedProgressBar(context, attrs)
+
+ assertThat(bar.progressBarBackgroundColor)
+ .isEqualTo(SegmentedProgressBar.DEFAULT_BACKGROUND_COLOR)
+ }
+
+ // endregion
+
+ // region validation
+
+ @Test
+ fun `divisions below one is rejected`() {
+ val bar = newBar()
+
+ for (invalid in listOf(0, -1, Int.MIN_VALUE)) {
+ val error = runCatching { bar.divisions = invalid }.exceptionOrNull()
+ assertThat(error).isInstanceOf(IllegalArgumentException::class.java)
+ }
+ // The rejected assignments left the view untouched.
+ assertThat(bar.divisions).isEqualTo(1)
+ }
+
+ @Test
+ fun `a negative divider width is rejected`() {
+ val bar = newBar()
+
+ val error = runCatching { bar.dividerWidth = -1f }.exceptionOrNull()
+
+ assertThat(error).isInstanceOf(IllegalArgumentException::class.java)
+ assertThat(error).hasMessageThat().contains("dividerWidth")
+ assertThat(bar.dividerWidth).isEqualTo(SegmentedProgressBar.DEFAULT_DIVIDER_WIDTH_PX)
+ }
+
+ @Test
+ fun `a non-finite divider width is rejected`() {
+ val bar = newBar()
+
+ for (invalid in listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY)) {
+ val error = runCatching { bar.dividerWidth = invalid }.exceptionOrNull()
+ assertThat(error).isInstanceOf(IllegalArgumentException::class.java)
+ }
+ }
+
+ @Test
+ fun `a negative corner radius is rejected`() {
+ val bar = newBar()
+
+ val error = runCatching { bar.cornerRadius = -0.5f }.exceptionOrNull()
+
+ assertThat(error).isInstanceOf(IllegalArgumentException::class.java)
+ assertThat(error).hasMessageThat().contains("cornerRadius")
+ }
+
+ @Test
+ fun `a zero divider width is accepted`() {
+ val bar = newBar()
+
+ bar.dividerWidth = 0f
+
+ assertThat(bar.dividerWidth).isEqualTo(0f)
+ }
+
+ // endregion
+
+ // region enabledDivisions
+
+ @Test
+ fun `enabled divisions are sorted and de-duplicated`() {
+ val bar = newBar().apply { divisions = 10 }
+
+ bar.enabledDivisions = listOf(5, 1, 5, 9, 1, 0)
+
+ assertThat(bar.enabledDivisions).containsExactly(0, 1, 5, 9).inOrder()
+ }
+
+ @Test
+ fun `negative enabled divisions are dropped`() {
+ val bar = newBar().apply { divisions = 5 }
+
+ bar.enabledDivisions = listOf(-3, 0, -1, 2)
+
+ assertThat(bar.enabledDivisions).containsExactly(0, 2).inOrder()
+ }
+
+ @Test
+ fun `out of range enabled divisions are retained so call order does not matter`() {
+ val bar = newBar()
+
+ // enabledDivisions assigned before divisions is widened.
+ bar.enabledDivisions = listOf(0, 3, 7)
+ assertThat(bar.completedSegmentCount).isEqualTo(1)
+
+ bar.divisions = 10
+ assertThat(bar.enabledDivisions).containsExactly(0, 3, 7).inOrder()
+ assertThat(bar.completedSegmentCount).isEqualTo(3)
+ }
+
+ @Test
+ fun `the caller's list is copied on assignment`() {
+ // Regression: 0.0.1 stored the caller's List by reference, so later
+ // mutations silently changed the view's state without a repaint.
+ val bar = newBar().apply { divisions = 10 }
+ val source = mutableListOf(1, 2)
+
+ bar.enabledDivisions = source
+ source.add(9)
+
+ assertThat(bar.enabledDivisions).containsExactly(1, 2).inOrder()
+ }
+
+ @Test
+ fun `completed segment count ignores out of range indices`() {
+ val bar = newBar().apply {
+ divisions = 4
+ enabledDivisions = listOf(0, 1, 4, 99)
+ }
+
+ assertThat(bar.completedSegmentCount).isEqualTo(2)
+ }
+
+ // endregion
+
+ @Test
+ fun `enableDivision keeps the selection sorted`() {
+ val bar = newBar().apply { divisions = 10 }
+
+ bar.enableDivision(5)
+ bar.enableDivision(1)
+ bar.enableDivision(8)
+ bar.enableDivision(3)
+
+ assertThat(bar.enabledDivisions).containsExactly(1, 3, 5, 8).inOrder()
+ }
+
+ @Test
+ fun `enableDivision is idempotent`() {
+ val bar = newBar().apply { divisions = 10 }
+
+ bar.enableDivision(4)
+ bar.enableDivision(4)
+
+ assertThat(bar.enabledDivisions).containsExactly(4)
+ }
+
+ @Test
+ fun `enableDivision ignores negative indices`() {
+ val bar = newBar().apply { divisions = 10 }
+
+ bar.enableDivision(-1)
+
+ assertThat(bar.enabledDivisions).isEmpty()
+ }
+
+ @Test
+ fun `disableDivision removes only the requested segment`() {
+ val bar = newBar().apply {
+ divisions = 10
+ enabledDivisions = listOf(1, 4, 7)
+ }
+
+ bar.disableDivision(4)
+
+ assertThat(bar.enabledDivisions).containsExactly(1, 7).inOrder()
+ }
+
+ @Test
+ fun `disableDivision of an unlit segment is a no-op`() {
+ val bar = newBar().apply {
+ divisions = 10
+ enabledDivisions = listOf(1)
+ }
+
+ bar.disableDivision(9)
+
+ assertThat(bar.enabledDivisions).containsExactly(1)
+ }
+
+ @Test
+ fun `isDivisionEnabled reports membership`() {
+ val bar = newBar().apply {
+ divisions = 10
+ enabledDivisions = listOf(2, 6)
+ }
+
+ assertThat(bar.isDivisionEnabled(2)).isTrue()
+ assertThat(bar.isDivisionEnabled(6)).isTrue()
+ assertThat(bar.isDivisionEnabled(3)).isFalse()
+ assertThat(bar.isDivisionEnabled(-1)).isFalse()
+ }
+
+ // endregion
+
+ // region toggleDivision
+
+ @Test
+ fun `toggleDivision flips a single segment and reports its new state`() {
+ val bar = newBar().apply {
+ divisions = 10
+ enabledDivisions = listOf(1, 2, 5, 6, 9)
+ }
+
+ assertThat(bar.toggleDivision(4)).isTrue()
+ assertThat(bar.enabledDivisions).containsExactly(1, 2, 4, 5, 6, 9).inOrder()
+
+ assertThat(bar.toggleDivision(4)).isFalse()
+ assertThat(bar.enabledDivisions).containsExactly(1, 2, 5, 6, 9).inOrder()
+ }
+
+ @Test
+ fun `toggleDivision leaves every other segment alone`() {
+ val bar = newBar().apply {
+ divisions = 10
+ enabledDivisions = listOf(1, 2, 5, 6, 9)
+ }
+
+ bar.toggleDivision(5)
+
+ assertThat(bar.enabledDivisions).containsExactly(1, 2, 6, 9).inOrder()
+ }
+
+ @Test
+ fun `toggleDivision ignores negative indices`() {
+ val bar = newBar().apply { divisions = 10 }
+
+ assertThat(bar.toggleDivision(-1)).isFalse()
+ assertThat(bar.enabledDivisions).isEmpty()
+ }
+
+ @Test
+ fun `an arbitrary subset can be built one toggle at a time in any order`() {
+ // The library's whole purpose, exercised the way a tap handler would.
+ val bar = newBar().apply { divisions = 10 }
+
+ listOf(9, 1, 6, 2, 5).forEach { bar.toggleDivision(it) }
+
+ assertThat(bar.enabledDivisions).containsExactly(1, 2, 5, 6, 9).inOrder()
+ assertThat(bar.completedSegmentCount).isEqualTo(5)
+ listOf(0, 3, 4, 7, 8).forEach { assertThat(bar.isDivisionEnabled(it)).isFalse() }
+ }
+
+ // endregion
+
+ // region divisionAt
+
+ @Test
+ fun `divisionAt maps a position to the segment containing it`() {
+ val bar = newBar().apply { divisions = 10 }.layoutAt(width = 500, height = 20)
+
+ // 500 / 10 == 50px per segment.
+ assertThat(bar.divisionAt(0f)).isEqualTo(0)
+ assertThat(bar.divisionAt(49.9f)).isEqualTo(0)
+ assertThat(bar.divisionAt(50f)).isEqualTo(1)
+ assertThat(bar.divisionAt(275f)).isEqualTo(5)
+ assertThat(bar.divisionAt(499.9f)).isEqualTo(9)
+ }
+
+ @Test
+ fun `divisionAt reports NO_DIVISION outside the bar`() {
+ val bar = newBar().apply { divisions = 10 }.layoutAt(width = 500, height = 20)
+
+ assertThat(bar.divisionAt(-1f)).isEqualTo(SegmentedProgressBar.NO_DIVISION)
+ assertThat(bar.divisionAt(500f)).isEqualTo(SegmentedProgressBar.NO_DIVISION)
+ assertThat(bar.divisionAt(1000f)).isEqualTo(SegmentedProgressBar.NO_DIVISION)
+ }
+
+ @Test
+ fun `divisionAt accounts for padding`() {
+ val bar = newBar().apply {
+ divisions = 4
+ setPadding(100, 0, 100, 0)
+ }.layoutAt(width = 500, height = 20)
+
+ // The content box is 300px wide starting at x=100, so 75px per segment.
+ assertThat(bar.divisionAt(99f)).isEqualTo(SegmentedProgressBar.NO_DIVISION)
+ assertThat(bar.divisionAt(100f)).isEqualTo(0)
+ assertThat(bar.divisionAt(180f)).isEqualTo(1)
+ assertThat(bar.divisionAt(399f)).isEqualTo(3)
+ assertThat(bar.divisionAt(400f)).isEqualTo(SegmentedProgressBar.NO_DIVISION)
+ }
+
+ @Test
+ fun `divisionAt never returns an out of range index`() {
+ val bar = newBar().apply { divisions = 7 }.layoutAt(width = 333, height = 20)
+
+ for (x in 0 until 333) {
+ val index = bar.divisionAt(x.toFloat())
+ assertThat(index).isAtLeast(0)
+ assertThat(index).isAtMost(6)
+ }
+ }
+
+ @Test
+ fun `divisionAt reports NO_DIVISION before the view has been laid out`() {
+ val bar = newBar().apply { divisions = 10 }
+
+ assertThat(bar.divisionAt(10f)).isEqualTo(SegmentedProgressBar.NO_DIVISION)
+ }
+
+ @Test
+ fun `divisionAt is the inverse of where a segment is drawn`() {
+ // Tapping the centre of segment i must resolve back to i, for every i.
+ val bar = newBar().apply { divisions = 10 }.layoutAt(width = 500, height = 20)
+ val segmentWidth = 500f / 10
+
+ for (index in 0 until 10) {
+ val centre = segmentWidth * index + segmentWidth / 2f
+ assertThat(bar.divisionAt(centre)).isEqualTo(index)
+ }
+ }
+
+ // endregion
+
+ // region division click listener
+
+ /**
+ * A bar attached to an activity and reliably laid out at a known size.
+ *
+ * Two Robolectric details make this fiddlier than it looks, and both would
+ * otherwise produce quietly wrong tests rather than failures:
+ *
+ * 1. `View.onTouchEvent` fires a click by `post`ing it. On a *detached* view
+ * that runnable sits in the run queue until attach, so it never executes
+ * and every touch assertion would pass vacuously by reporting nothing.
+ * 2. Draining the looper runs a real layout pass, which overwrites any size
+ * set by calling `layout()` directly and replaces it with the window
+ * width. The size therefore has to come from exact `LayoutParams`, so
+ * that re-laying out is idempotent.
+ */
+ private fun touchableBar(
+ width: Int = 500,
+ height: Int = 20,
+ configure: SegmentedProgressBar.() -> Unit,
+ ): SegmentedProgressBar {
+ val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
+ val bar = SegmentedProgressBar(activity).apply(configure)
+ activity.setContentView(bar, ViewGroup.LayoutParams(width, height))
+ shadowOf(Looper.getMainLooper()).idle()
+ check(bar.width == width) {
+ "expected the bar to be laid out ${width}px wide but it was ${bar.width}px; " +
+ "every coordinate assertion below would be measuring the wrong bar"
+ }
+ return bar
+ }
+
+ /** Dispatches a full down/up touch at [x] and drains the posted click. */
+ private fun SegmentedProgressBar.touchAt(x: Float) {
+ val now = SystemClock.uptimeMillis()
+ dispatchTouchEvent(MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, 5f, 0))
+ dispatchTouchEvent(MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, 5f, 0))
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+
+ @Test
+ fun `a tap reports the segment that was hit`() {
+ val tapped = mutableListOf()
+ val bar = touchableBar {
+ divisions = 10
+ setOnDivisionClickListener { _, index -> tapped += index }
+ }
+
+ bar.touchAt(275f) // segment 5 spans 250..300
+ bar.touchAt(30f) // segment 0 spans 0..50
+
+ assertThat(tapped).containsExactly(5, 0).inOrder()
+ }
+
+ @Test
+ fun `the listener receives the bar it was registered on`() {
+ var received: SegmentedProgressBar? = null
+ val bar = touchableBar {
+ divisions = 4
+ setOnDivisionClickListener { view, _ -> received = view }
+ }
+
+ bar.touchAt(50f)
+
+ assertThat(received).isSameInstanceAs(bar)
+ }
+
+ @Test
+ fun `registering a listener makes the view clickable and focusable`() {
+ val bar = newBar()
+
+ assertThat(bar.isClickable).isFalse()
+
+ bar.setOnDivisionClickListener { _, _ -> }
+ assertThat(bar.isClickable).isTrue()
+ assertThat(bar.isFocusable).isTrue()
+
+ bar.setOnDivisionClickListener(null)
+ assertThat(bar.isClickable).isFalse()
+ assertThat(bar.isFocusable).isFalse()
+ }
+
+ @Test
+ fun `a removed listener stops being notified`() {
+ val tapped = mutableListOf()
+ val bar = touchableBar {
+ divisions = 10
+ setOnDivisionClickListener { _, index -> tapped += index }
+ }
+
+ bar.touchAt(275f)
+ bar.setOnDivisionClickListener(null)
+ bar.touchAt(30f)
+
+ assertThat(tapped).containsExactly(5)
+ }
+
+ @Test
+ fun `the listener only reports the tap and does not change state itself`() {
+ // Deciding what a tap means is the caller's job.
+ var notified = false
+ val bar = touchableBar {
+ divisions = 10
+ setOnDivisionClickListener { _, _ -> notified = true }
+ }
+
+ bar.touchAt(275f)
+
+ assertThat(notified).isTrue()
+ assertThat(bar.enabledDivisions).isEmpty()
+ }
+
+ @Test
+ fun `a tap in the padding reports nothing but a tap on the track does`() {
+ val tapped = mutableListOf()
+ val bar = touchableBar {
+ divisions = 4
+ setPadding(100, 0, 100, 0)
+ setOnDivisionClickListener { _, index -> tapped += index }
+ }
+
+ bar.touchAt(50f) // inside the left padding
+ assertThat(tapped).isEmpty()
+
+ // Positive control, so this test cannot pass by simply never firing.
+ bar.touchAt(150f) // content box starts at 100, 75px per segment
+ assertThat(tapped).containsExactly(0)
+ }
+
+ @Test
+ fun `an accessibility style click with no pointer position reports nothing`() {
+ // performClick() carries no coordinates, so there is no segment to infer.
+ val tapped = mutableListOf()
+ val bar = touchableBar {
+ divisions = 10
+ setOnDivisionClickListener { _, index -> tapped += index }
+ }
+
+ bar.performClick()
+ assertThat(tapped).isEmpty()
+
+ // Positive control.
+ bar.touchAt(275f)
+ assertThat(tapped).containsExactly(5)
+ }
+
+ @Test
+ fun `each tap is resolved independently rather than reusing a stale position`() {
+ val tapped = mutableListOf()
+ val bar = touchableBar {
+ divisions = 10
+ setOnDivisionClickListener { _, index -> tapped += index }
+ }
+
+ bar.touchAt(275f)
+ bar.performClick() // no new touch, so nothing more should arrive
+
+ assertThat(tapped).containsExactly(5)
+ }
+
+ @Test
+ fun `tapping every segment in turn reports each index exactly once`() {
+ val tapped = mutableListOf()
+ val bar = touchableBar {
+ divisions = 10
+ setOnDivisionClickListener { _, index -> tapped += index }
+ }
+
+ for (index in 0 until 10) {
+ bar.touchAt(50f * index + 25f)
+ }
+
+ assertThat(tapped).containsExactly(0, 1, 2, 3, 4, 5, 6, 7, 8, 9).inOrder()
+ }
+
+ @Test
+ fun `toggling from the listener builds an arbitrary subset`() {
+ // End to end: the interaction this library exists to support.
+ val bar = touchableBar {
+ divisions = 10
+ setOnDivisionClickListener { view, index -> view.toggleDivision(index) }
+ }
+
+ listOf(9, 1, 6, 2, 5).forEach { bar.touchAt(50f * it + 25f) }
+
+ assertThat(bar.enabledDivisions).containsExactly(1, 2, 5, 6, 9).inOrder()
+
+ // Tapping a lit segment clears just that one.
+ bar.touchAt(50f * 5 + 25f)
+ assertThat(bar.enabledDivisions).containsExactly(1, 2, 6, 9).inOrder()
+ }
+
+ // endregion
+
+ // region tap to toggle
+
+ @Test
+ fun `tap to toggle is off by default`() {
+ val bar = newBar()
+
+ assertThat(bar.isTapToToggleEnabled).isFalse()
+ assertThat(bar.isClickable).isFalse()
+ }
+
+ @Test
+ fun `tap to toggle makes the bar interactive with no listener at all`() {
+ val bar = touchableBar {
+ divisions = 10
+ isTapToToggleEnabled = true
+ }
+
+ listOf(9, 1, 6, 2, 5).forEach { bar.touchAt(50f * it + 25f) }
+ assertThat(bar.enabledDivisions).containsExactly(1, 2, 5, 6, 9).inOrder()
+
+ bar.touchAt(50f * 5 + 25f)
+ assertThat(bar.enabledDivisions).containsExactly(1, 2, 6, 9).inOrder()
+ }
+
+ @Test
+ fun `turning tap to toggle on makes the view clickable and focusable`() {
+ val bar = newBar()
+
+ bar.isTapToToggleEnabled = true
+ assertThat(bar.isClickable).isTrue()
+ assertThat(bar.isFocusable).isTrue()
+
+ bar.isTapToToggleEnabled = false
+ assertThat(bar.isClickable).isFalse()
+ assertThat(bar.isFocusable).isFalse()
+ }
+
+ @Test
+ fun `a listener still works alongside tap to toggle, and sees the new state`() {
+ // The order matters: a listener that renders a label from the bar would
+ // otherwise draw the state from before the tap.
+ val states = mutableListOf()
+ val bar = touchableBar {
+ divisions = 4
+ isTapToToggleEnabled = true
+ setOnDivisionClickListener { view, index -> states += view.isDivisionEnabled(index) }
+ }
+
+ bar.touchAt(150f) // segment 1 spans 125..250
+ bar.touchAt(150f)
+
+ assertThat(states).containsExactly(true, false).inOrder()
+ assertThat(bar.enabledDivisions).isEmpty()
+ }
+
+ @Test
+ fun `clearing a listener leaves a tap to toggle bar interactive`() {
+ val bar = touchableBar {
+ divisions = 4
+ isTapToToggleEnabled = true
+ setOnDivisionClickListener { _, _ -> }
+ }
+
+ bar.setOnDivisionClickListener(null)
+ bar.touchAt(150f)
+
+ assertThat(bar.isClickable).isTrue()
+ assertThat(bar.enabledDivisions).containsExactly(1)
+ }
+
+ @Test
+ fun `an unclickable bar ignores taps however it was configured`() {
+ // The documented off switch: the platform's own gate, which View
+ // .onTouchEvent honours before any of this class's code runs.
+ val bar = touchableBar {
+ divisions = 4
+ isTapToToggleEnabled = true
+ }
+ bar.isClickable = false
+
+ bar.touchAt(150f)
+
+ assertThat(bar.enabledDivisions).isEmpty()
+ }
+
+ @Test
+ fun `a tap outside the bar toggles nothing`() {
+ val bar = touchableBar {
+ divisions = 4
+ setPadding(100, 0, 100, 0)
+ isTapToToggleEnabled = true
+ }
+
+ bar.touchAt(50f) // inside the left padding
+ assertThat(bar.enabledDivisions).isEmpty()
+
+ // Positive control, so this cannot pass by never toggling anything.
+ bar.touchAt(150f)
+ assertThat(bar.enabledDivisions).isNotEmpty()
+ }
+
+ // endregion
+
+ // region reset
+
+ @Test
+ fun `reset clears progress but preserves configuration`() {
+ // Regression: 0.0.1's reset() cleared the internal divider positions and
+ // left enabledDivisions untouched, so it wiped the dividers and kept the
+ // progress, the exact opposite of what it claimed to do.
+ val bar = newBar().apply {
+ divisions = 8
+ dividerWidth = 4f
+ cornerRadius = 6f
+ progressBarColor = Color.RED
+ dividerColor = Color.BLUE
+ progressBarBackgroundColor = Color.GREEN
+ enabledDivisions = listOf(0, 1, 2)
+ }
+
+ bar.reset()
+
+ assertThat(bar.enabledDivisions).isEmpty()
+ assertThat(bar.completedSegmentCount).isEqualTo(0)
+ assertThat(bar.divisions).isEqualTo(8)
+ assertThat(bar.dividerWidth).isEqualTo(4f)
+ assertThat(bar.cornerRadius).isEqualTo(6f)
+ assertThat(bar.progressBarColor).isEqualTo(Color.RED)
+ assertThat(bar.dividerColor).isEqualTo(Color.BLUE)
+ assertThat(bar.progressBarBackgroundColor).isEqualTo(Color.GREEN)
+ }
+
+ // endregion
+
+ // region repainting
+
+ @Test
+ fun `every setter requests a repaint`() {
+ // Regression: in 0.0.1 only setDivisions and setEnabledDivisions
+ // invalidated, so programmatic colour, divider and corner changes did
+ // not appear until something else happened to redraw the view.
+ val bar = newBar().apply { divisions = 4 }
+
+ assertRepaints(bar) { divisions = 6 }
+ assertRepaints(bar) { enabledDivisions = listOf(1) }
+ assertRepaints(bar) { progressBarColor = Color.RED }
+ assertRepaints(bar) { progressBarBackgroundColor = Color.GREEN }
+ assertRepaints(bar) { dividerColor = Color.BLUE }
+ assertRepaints(bar) { dividerWidth = 9f }
+ assertRepaints(bar) { isDividerEnabled = false }
+ assertRepaints(bar) { cornerRadius = 11f }
+ assertRepaints(bar) { enableDivision(3) }
+ assertRepaints(bar) { disableDivision(3) }
+ assertRepaints(bar) { reset() }
+ }
+
+ @Test
+ fun `re-setting an unchanged value does not request a repaint`() {
+ val bar = newBar().apply {
+ divisions = 4
+ progressBarColor = Color.RED
+ enabledDivisions = listOf(1, 2)
+ }
+
+ assertDoesNotRepaint(bar) { divisions = 4 }
+ assertDoesNotRepaint(bar) { progressBarColor = Color.RED }
+ assertDoesNotRepaint(bar) { enabledDivisions = listOf(2, 1) }
+ assertDoesNotRepaint(bar) { enableDivision(1) }
+ assertDoesNotRepaint(bar) { disableDivision(3) }
+ }
+
+ @Test
+ fun `reset on an already empty bar does not request a repaint`() {
+ val bar = newBar().apply { divisions = 4 }
+
+ assertDoesNotRepaint(bar) { reset() }
+ }
+
+ private fun assertRepaints(
+ bar: SegmentedProgressBar,
+ block: SegmentedProgressBar.() -> Unit,
+ ) {
+ shadowOf(bar).clearWasInvalidated()
+ bar.block()
+ assertThat(shadowOf(bar).wasInvalidated()).isTrue()
+ }
+
+ private fun assertDoesNotRepaint(
+ bar: SegmentedProgressBar,
+ block: SegmentedProgressBar.() -> Unit,
+ ) {
+ shadowOf(bar).clearWasInvalidated()
+ bar.block()
+ assertThat(shadowOf(bar).wasInvalidated()).isFalse()
+ }
+
+ // endregion
+
+ // region deprecated api
+
+ @Test
+ @Suppress("DEPRECATION")
+ fun `the deprecated setBackgroundColor still paints the track`() {
+ // Kept working on purpose so 0.0.1 call sites behave identically.
+ val bar = newBar()
+
+ bar.setBackgroundColor(Color.MAGENTA)
+
+ assertThat(bar.progressBarBackgroundColor).isEqualTo(Color.MAGENTA)
+ }
+
+ // endregion
+
+ // region measurement
+
+ @Test
+ fun `wrap_content falls back to an intrinsic size instead of collapsing`() {
+ // Regression: the sample layout in 0.0.1 used wrap_content for width and
+ // the view provided no onMeasure, so its intrinsic size was undefined.
+ val bar = newBar()
+ val density = context.resources.displayMetrics.density
+
+ bar.measure(
+ MeasureSpec.makeMeasureSpec(2000, MeasureSpec.AT_MOST),
+ MeasureSpec.makeMeasureSpec(2000, MeasureSpec.AT_MOST),
+ )
+
+ assertThat(bar.measuredWidth).isEqualTo((144f * density).toInt())
+ assertThat(bar.measuredHeight).isEqualTo((8f * density).toInt())
+ }
+
+ @Test
+ fun `an exact measure spec is honoured`() {
+ val bar = newBar()
+
+ bar.measure(
+ MeasureSpec.makeMeasureSpec(500, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(24, MeasureSpec.EXACTLY),
+ )
+
+ assertThat(bar.measuredWidth).isEqualTo(500)
+ assertThat(bar.measuredHeight).isEqualTo(24)
+ }
+
+ @Test
+ fun `wrap_content is capped by the available space`() {
+ val bar = newBar()
+
+ bar.measure(
+ MeasureSpec.makeMeasureSpec(40, MeasureSpec.AT_MOST),
+ MeasureSpec.makeMeasureSpec(6, MeasureSpec.AT_MOST),
+ )
+
+ assertThat(bar.measuredWidth).isEqualTo(40)
+ assertThat(bar.measuredHeight).isEqualTo(6)
+ }
+
+ @Test
+ fun `padding is added to the intrinsic size`() {
+ val bar = newBar()
+ val density = context.resources.displayMetrics.density
+ bar.setPadding(10, 5, 20, 7)
+
+ bar.measure(
+ MeasureSpec.makeMeasureSpec(2000, MeasureSpec.AT_MOST),
+ MeasureSpec.makeMeasureSpec(2000, MeasureSpec.AT_MOST),
+ )
+
+ assertThat(bar.measuredWidth).isEqualTo((144f * density).toInt() + 30)
+ assertThat(bar.measuredHeight).isEqualTo((8f * density).toInt() + 12)
+ }
+
+ @Test
+ fun `an explicit minimum height wins over the intrinsic height`() {
+ val bar = newBar()
+ bar.minimumHeight = 100
+
+ bar.measure(
+ MeasureSpec.makeMeasureSpec(2000, MeasureSpec.AT_MOST),
+ MeasureSpec.makeMeasureSpec(2000, MeasureSpec.AT_MOST),
+ )
+
+ assertThat(bar.measuredHeight).isEqualTo(100)
+ }
+
+ // endregion
+
+ // region instance state
+
+ @Test
+ fun `progress survives a save and restore cycle`() {
+ val saved = newBar().apply {
+ id = View.generateViewId()
+ divisions = 12
+ enabledDivisions = listOf(0, 3, 11)
+ }
+ val restored = newBar().apply { id = saved.id }
+
+ transferHierarchyState(from = saved, to = restored)
+
+ assertThat(restored.divisions).isEqualTo(12)
+ assertThat(restored.enabledDivisions).containsExactly(0, 3, 11).inOrder()
+ assertThat(restored.completedSegmentCount).isEqualTo(3)
+ }
+
+ @Test
+ fun `an empty bar survives a save and restore cycle`() {
+ val saved = newBar().apply {
+ id = View.generateViewId()
+ divisions = 5
+ }
+ val restored = newBar().apply { id = saved.id }
+
+ transferHierarchyState(from = saved, to = restored)
+
+ assertThat(restored.divisions).isEqualTo(5)
+ assertThat(restored.enabledDivisions).isEmpty()
+ }
+
+ @Test
+ fun `state written by some other view under the same id is ignored`() {
+ // Ids are reused across layouts, so the view has to tolerate finding
+ // state it did not write rather than casting blindly.
+ val bar = newBar().apply {
+ id = View.generateViewId()
+ divisions = 4
+ enabledDivisions = listOf(1)
+ }
+ val container = SparseArray()
+ container.put(bar.id, AbsSavedState.EMPTY_STATE)
+
+ bar.restoreHierarchyState(container)
+
+ assertThat(bar.divisions).isEqualTo(4)
+ assertThat(bar.enabledDivisions).containsExactly(1)
+ }
+
+ /**
+ * Round-trips view state through a [SparseArray] the way the framework does
+ * on configuration change, including a real [Parcel] hop so the
+ * [Parcelable] implementation is genuinely exercised rather than passed
+ * through by reference.
+ */
+ private fun transferHierarchyState(from: SegmentedProgressBar, to: SegmentedProgressBar) {
+ val container = SparseArray()
+ from.saveHierarchyState(container)
+
+ val parcel = Parcel.obtain()
+ try {
+ parcel.writeSparseArray(container)
+ parcel.setDataPosition(0)
+ val revived = parcel.readSparseArray(
+ SegmentedProgressBar::class.java.classLoader,
+ Parcelable::class.java,
+ )
+ checkNotNull(revived) { "state did not survive the parcel round-trip" }
+ to.restoreHierarchyState(revived)
+ } finally {
+ parcel.recycle()
+ }
+ }
+
+ // endregion
+
+ // region accessibility
+
+ @Test
+ fun `the bar reports itself as a progress bar to accessibility services`() {
+ val bar = attachedBar {
+ divisions = 5
+ enabledDivisions = listOf(0, 1)
+ }
+
+ val info = accessibilityNodeOf(bar)
+
+ assertThat(info.className.toString()).isEqualTo(ProgressBar::class.java.name)
+ }
+
+ @Test
+ fun `a content description is generated from the current progress`() {
+ val bar = attachedBar {
+ divisions = 5
+ enabledDivisions = listOf(0, 1)
+ }
+
+ val info = accessibilityNodeOf(bar)
+
+ assertThat(info.contentDescription.toString()).isEqualTo("2 of 5 segments complete")
+ }
+
+ @Test
+ fun `a caller-supplied content description is not overwritten`() {
+ val bar = attachedBar {
+ divisions = 5
+ enabledDivisions = listOf(0, 1)
+ contentDescription = "Onboarding progress"
+ }
+
+ val info = accessibilityNodeOf(bar)
+
+ assertThat(info.contentDescription.toString()).isEqualTo("Onboarding progress")
+ }
+
+ @Test
+ fun `the generated content description is grammatically singular for one division`() {
+ val bar = attachedBar {
+ divisions = 1
+ enabledDivisions = listOf(0)
+ }
+
+ val info = accessibilityNodeOf(bar)
+
+ assertThat(info.contentDescription.toString()).isEqualTo("1 of 1 segment complete")
+ }
+
+ @Test
+ fun `the generated content description counts only drawn segments`() {
+ val bar = attachedBar {
+ divisions = 3
+ enabledDivisions = listOf(0, 1, 50)
+ }
+
+ val info = accessibilityNodeOf(bar)
+
+ assertThat(info.contentDescription.toString()).isEqualTo("2 of 3 segments complete")
+ }
+
+ // endregion
+
+ // region integration
+
+ @Test
+ fun `the view draws without error inside a parent at a realistic size`() {
+ val parent = FrameLayout(context)
+ val bar = newBar().apply {
+ divisions = 10
+ dividerWidth = 4f
+ cornerRadius = 8f
+ enabledDivisions = listOf(0, 1, 2, 3, 4, 5)
+ }
+ parent.addView(bar)
+
+ bar.layoutAt(width = 600, height = 32)
+
+ assertThat(bar.width).isEqualTo(600)
+ assertThat(bar.height).isEqualTo(32)
+ }
+
+ // endregion
+}
diff --git a/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarVariantsTest.kt b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarVariantsTest.kt
new file mode 100644
index 0000000..c5896dc
--- /dev/null
+++ b/segmented/src/test/java/com/rachitgoyal/segmented/SegmentedProgressBarVariantsTest.kt
@@ -0,0 +1,1122 @@
+package com.rachitgoyal.segmented
+
+import android.content.Context
+import android.graphics.Color
+import android.os.Looper
+import android.view.View
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import java.time.Duration
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
+
+/**
+ * Tests for the visual variants added in 2.0.0: height bands, corner modes, drop
+ * shadow and segment animation.
+ *
+ * The fixture is a 300 x 40 bar with 4 divisions and no divider, so each cell is
+ * exactly 75px wide and the arithmetic in the assertions stays checkable by eye:
+ * ```
+ * segment 0: 0 .. 75
+ * segment 1: 75 .. 150
+ * segment 2: 150 .. 225
+ * segment 3: 225 .. 300
+ * ```
+ */
+@RunWith(AndroidJUnit4::class)
+class SegmentedProgressBarVariantsTest {
+
+ private companion object {
+ const val WIDTH = 300
+ const val HEIGHT = 40
+ const val DIVISIONS = 4
+
+ const val PROGRESS = Color.RED
+ const val TRACK = Color.GREEN
+ }
+
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ }
+
+ private fun newBar(configure: SegmentedProgressBar.() -> Unit = {}) =
+ SegmentedProgressBar(context).apply {
+ divisions = DIVISIONS
+ dividerWidth = 0f
+ cornerRadius = 0f
+ progressBarColor = PROGRESS
+ progressBarBackgroundColor = TRACK
+ configure()
+ }
+
+ private fun render(bar: SegmentedProgressBar) =
+ renderToRecordingCanvas(bar, WIDTH, HEIGHT)
+
+ private fun advance(millis: Long) {
+ shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(millis))
+ }
+
+ // region 1. height difference
+
+ @Test
+ fun `by default the track and segments fill the full height`() {
+ val bar = newBar { enabledDivisions = listOf(0) }
+
+ val canvas = render(bar)
+
+ val track = canvas.ops.ofColor(TRACK).union()
+ assertThat(track.top).isWithin(0.01f).of(0f)
+ assertThat(track.bottom).isWithin(0.01f).of(HEIGHT.toFloat())
+
+ val segment = canvas.ops.ofColor(PROGRESS).single()
+ assertThat(segment.top).isWithin(0.01f).of(0f)
+ assertThat(segment.bottom).isWithin(0.01f).of(HEIGHT.toFloat())
+ }
+
+ @Test
+ fun `a thinner track is centred vertically`() {
+ val bar = newBar { inactiveHeightRatio = 0.5f }
+
+ val track = render(bar).ops.ofColor(TRACK).union()
+
+ // Half of 40 is 20, centred leaves 10px above and below.
+ assertThat(track.top).isWithin(0.01f).of(10f)
+ assertThat(track.bottom).isWithin(0.01f).of(30f)
+ assertThat(track.height).isWithin(0.01f).of(20f)
+ }
+
+ @Test
+ fun `lit segments can stand proud of a thinner track`() {
+ // The headline use of this: a slim rail with chunky completed segments.
+ val bar = newBar {
+ inactiveHeightRatio = 0.4f
+ activeHeightRatio = 1f
+ enabledDivisions = listOf(1)
+ }
+
+ val canvas = render(bar)
+
+ val track = canvas.ops.ofColor(TRACK).union()
+ val segment = canvas.ops.ofColor(PROGRESS).single()
+
+ assertThat(track.height).isWithin(0.01f).of(16f)
+ assertThat(segment.height).isWithin(0.01f).of(40f)
+ // Both centred on the same axis.
+ assertThat(track.top + track.bottom).isWithin(0.01f).of(segment.top + segment.bottom)
+ }
+
+ @Test
+ fun `lit segments can also be thinner than the track`() {
+ val bar = newBar {
+ inactiveHeightRatio = 1f
+ activeHeightRatio = 0.5f
+ enabledDivisions = listOf(1)
+ }
+
+ val canvas = render(bar)
+
+ assertThat(canvas.ops.ofColor(TRACK).union().height).isWithin(0.01f).of(40f)
+ assertThat(canvas.ops.ofColor(PROGRESS).single().height).isWithin(0.01f).of(20f)
+ }
+
+ @Test
+ fun `dividers span the taller of the two bands`() {
+ val bar = newBar {
+ dividerWidth = 4f
+ dividerColor = Color.BLUE
+ inactiveHeightRatio = 0.25f
+ activeHeightRatio = 1f
+ }
+
+ val dividers = render(bar).ops.ofColor(Color.BLUE)
+
+ assertThat(dividers).hasSize(DIVISIONS - 1)
+ // Otherwise a tall lit segment would be visually unseparated from its
+ // neighbour wherever the thin track's divider stopped short.
+ dividers.forEach {
+ assertThat(it.top).isWithin(0.01f).of(0f)
+ assertThat(it.bottom).isWithin(0.01f).of(HEIGHT.toFloat())
+ }
+ }
+
+ @Test
+ fun `height ratios outside zero to one are rejected`() {
+ val bar = newBar()
+
+ for (invalid in listOf(-0.1f, 1.1f, Float.NaN, Float.POSITIVE_INFINITY)) {
+ assertThat(runCatching { bar.activeHeightRatio = invalid }.exceptionOrNull())
+ .isInstanceOf(IllegalArgumentException::class.java)
+ assertThat(runCatching { bar.inactiveHeightRatio = invalid }.exceptionOrNull())
+ .isInstanceOf(IllegalArgumentException::class.java)
+ }
+ assertThat(bar.activeHeightRatio).isEqualTo(1f)
+ assertThat(bar.inactiveHeightRatio).isEqualTo(1f)
+ }
+
+ @Test
+ fun `a zero height ratio draws nothing for that band`() {
+ val bar = newBar {
+ inactiveHeightRatio = 0f
+ enabledDivisions = listOf(0)
+ }
+
+ val canvas = render(bar)
+
+ assertThat(canvas.ops.ofColor(TRACK)).isEmpty()
+ assertThat(canvas.ops.ofColor(PROGRESS)).hasSize(1)
+ }
+
+ // endregion
+
+ // region 2 & 3. corner modes
+
+ @Test
+ fun `bar_ends is the default and rounds only the outer ends`() {
+ val bar = newBar {
+ cornerRadius = 8f
+ enabledDivisions = listOf(0, 1, 2, 3)
+ }
+
+ assertThat(bar.cornerMode).isEqualTo(CornerMode.BAR_ENDS)
+ val segments = render(bar).ops.ofColor(PROGRESS).sortedBy { it.left }
+ assertThat(segments.map { it.rounded }).containsExactly(true, false, false, true).inOrder()
+ }
+
+ @Test
+ fun `each_segment rounds every lit segment`() {
+ val bar = newBar {
+ cornerRadius = 8f
+ cornerMode = CornerMode.EACH_SEGMENT
+ enabledDivisions = listOf(0, 1, 2, 3)
+ }
+
+ val segments = render(bar).ops.ofColor(PROGRESS)
+
+ assertThat(segments).hasSize(4)
+ assertThat(segments.map { it.rounded }).doesNotContain(false)
+ }
+
+ @Test
+ fun `each_segment rounds every track cell, not just the ends`() {
+ // Otherwise rounded segments would sit on a squared-off strip.
+ val bar = newBar {
+ cornerRadius = 8f
+ cornerMode = CornerMode.EACH_SEGMENT
+ }
+
+ val track = render(bar).ops.ofColor(TRACK)
+
+ assertThat(track).hasSize(DIVISIONS)
+ assertThat(track.map { it.rounded }).doesNotContain(false)
+ val sorted = track.sortedBy { it.left }
+ assertThat(sorted[0].left).isWithin(0.01f).of(0f)
+ assertThat(sorted[0].right).isWithin(0.01f).of(75f)
+ assertThat(sorted[3].right).isWithin(0.01f).of(300f)
+ }
+
+ @Test
+ fun `each_run rounds only the outer ends of a contiguous run`() {
+ val bar = newBar {
+ cornerRadius = 8f
+ cornerMode = CornerMode.EACH_RUN
+ // A run of two, then a gap, then a lone segment.
+ enabledDivisions = listOf(0, 1, 3)
+ }
+
+ val segments = render(bar).ops.ofColor(PROGRESS).sortedBy { it.left }
+
+ assertThat(segments).hasSize(3)
+ // Segment 0 opens the run: rounded on the left, square where it meets 1.
+ assertThat(segments[0].rounded).isTrue()
+ // Segment 1 closes the run: square on the left, rounded on the right.
+ assertThat(segments[1].rounded).isTrue()
+ // Segment 3 is isolated, so both its ends are rounded.
+ assertThat(segments[2].rounded).isTrue()
+ }
+
+ @Test
+ fun `each_run leaves interior edges of a run square`() {
+ // Asserted on geometry rather than the rounded flag: a fully-square
+ // middle segment falls back to drawRect, which is the observable
+ // difference between "in a run" and "isolated".
+ val bar = newBar {
+ cornerRadius = 8f
+ cornerMode = CornerMode.EACH_RUN
+ enabledDivisions = listOf(0, 1, 2)
+ }
+
+ val segments = render(bar).ops.ofColor(PROGRESS).sortedBy { it.left }
+
+ assertThat(segments).hasSize(3)
+ assertThat(segments[0].rounded).isTrue() // opens the run
+ assertThat(segments[1].rounded).isFalse() // fully interior, so square
+ assertThat(segments[2].rounded).isTrue() // closes the run
+ }
+
+ @Test
+ fun `each_run treats every isolated segment as its own pill`() {
+ val bar = newBar {
+ cornerRadius = 8f
+ cornerMode = CornerMode.EACH_RUN
+ enabledDivisions = listOf(0, 2)
+ }
+
+ val segments = render(bar).ops.ofColor(PROGRESS)
+
+ assertThat(segments).hasSize(2)
+ assertThat(segments.map { it.rounded }).doesNotContain(false)
+ }
+
+ @Test
+ fun `each_run rounds the track under a segment to match it`() {
+ // Regression: the track cell kept its square interior corner while the
+ // segment above it was rounded at the end of a run, so the square corner
+ // showed through as an off-coloured wedge under the rounded edge.
+ val bar = newBar {
+ cornerRadius = 8f
+ cornerMode = CornerMode.EACH_RUN
+ // A run of two at the start, then two off.
+ enabledDivisions = listOf(0, 1)
+ }
+
+ val canvas = render(bar)
+
+ val track = canvas.ops.ofColor(TRACK).sortedBy { it.left }
+ val segments = canvas.ops.ofColor(PROGRESS).sortedBy { it.left }
+
+ assertThat(track).hasSize(DIVISIONS)
+ assertThat(segments).hasSize(2)
+
+ // Cell 1 closes the run, so its segment is rounded on the right; the
+ // track beneath it has to be rounded too.
+ assertThat(segments[1].rounded).isTrue()
+ assertThat(track[1].rounded).isTrue()
+
+ // Cell 2 is off and interior, so it stays square, as the rail should.
+ assertThat(track[2].rounded).isFalse()
+ }
+
+ @Test
+ fun `each_run leaves the track as a plain rail where no segment covers it`() {
+ val bar = newBar {
+ cornerRadius = 8f
+ cornerMode = CornerMode.EACH_RUN
+ enabledDivisions = emptyList()
+ }
+
+ val track = render(bar).ops.ofColor(TRACK).sortedBy { it.left }
+
+ // Only the two ends of the bar are rounded.
+ assertThat(track.map { it.rounded }).containsExactly(true, false, false, true).inOrder()
+ }
+
+ @Test
+ fun `changing the corner mode requests a repaint`() {
+ val bar = newBar()
+ shadowOf(bar).clearWasInvalidated()
+
+ bar.cornerMode = CornerMode.EACH_SEGMENT
+
+ assertThat(shadowOf(bar).wasInvalidated()).isTrue()
+ }
+
+ // endregion
+
+ // region 4. drop shadow
+
+ @Test
+ fun `a shadow is off by default`() {
+ val bar = newBar()
+
+ assertThat(bar.shadowRadius).isEqualTo(0f)
+ assertThat(bar.layerType).isEqualTo(View.LAYER_TYPE_NONE)
+ }
+
+ @Test
+ fun `enabling a shadow switches the view to a software layer`() {
+ // Android ignores Paint shadow layers for shapes on a hardware canvas,
+ // so without this the shadow would silently not render.
+ val bar = newBar { shadowRadius = 6f }
+
+ assertThat(bar.layerType).isEqualTo(View.LAYER_TYPE_SOFTWARE)
+ }
+
+ @Test
+ fun `clearing the shadow releases the software layer`() {
+ val bar = newBar { shadowRadius = 6f }
+
+ bar.shadowRadius = 0f
+
+ assertThat(bar.layerType).isEqualTo(View.LAYER_TYPE_NONE)
+ }
+
+ @Test
+ fun `shadow properties round-trip`() {
+ val bar = newBar {
+ shadowRadius = 5f
+ shadowDx = 2f
+ shadowDy = 3f
+ shadowColor = Color.BLUE
+ }
+
+ assertThat(bar.shadowRadius).isEqualTo(5f)
+ assertThat(bar.shadowDx).isEqualTo(2f)
+ assertThat(bar.shadowDy).isEqualTo(3f)
+ assertThat(bar.shadowColor).isEqualTo(Color.BLUE)
+ assertThat(bar.layerType).isEqualTo(View.LAYER_TYPE_SOFTWARE)
+ }
+
+ @Test
+ fun `a negative shadow radius is rejected`() {
+ val bar = newBar()
+
+ val error = runCatching { bar.shadowRadius = -1f }.exceptionOrNull()
+
+ assertThat(error).isInstanceOf(IllegalArgumentException::class.java)
+ assertThat(error).hasMessageThat().contains("shadowRadius")
+ }
+
+ @Test
+ fun `a non-finite shadow offset is rejected`() {
+ val bar = newBar()
+
+ assertThat(runCatching { bar.shadowDx = Float.NaN }.exceptionOrNull())
+ .isInstanceOf(IllegalArgumentException::class.java)
+ assertThat(runCatching { bar.shadowDy = Float.POSITIVE_INFINITY }.exceptionOrNull())
+ .isInstanceOf(IllegalArgumentException::class.java)
+ }
+
+ @Test
+ fun `a shadow does not change the bar's geometry at all`() {
+ // Regression: the bar used to inset itself to make room for the blur,
+ // which meant enabling a shadow, or changing its blur or offset, visibly
+ // shrank and shifted the bar.
+ val plain = newBar { enabledDivisions = listOf(0, 1, 2, 3) }
+ val shadowed = newBar {
+ shadowRadius = 8f
+ shadowDy = 5f
+ enabledDivisions = listOf(0, 1, 2, 3)
+ }
+
+ val a = render(plain).ops.ofColor(PROGRESS).sortedBy { it.left }
+ val b = render(shadowed).ops.ofColor(PROGRESS).sortedBy { it.left }
+
+ assertThat(b).hasSize(a.size)
+ a.indices.forEach { i ->
+ assertThat(b[i].left).isWithin(0.01f).of(a[i].left)
+ assertThat(b[i].right).isWithin(0.01f).of(a[i].right)
+ assertThat(b[i].top).isWithin(0.01f).of(a[i].top)
+ assertThat(b[i].bottom).isWithin(0.01f).of(a[i].bottom)
+ }
+ }
+
+ @Test
+ fun `changing the blur or offset never moves the bar`() {
+ val bar = newBar { enabledDivisions = listOf(1) }
+ val baseline = render(bar).ops.ofColor(PROGRESS).single()
+
+ bar.shadowRadius = 12f
+ bar.shadowDx = 6f
+ bar.shadowDy = 9f
+ val shifted = render(bar).ops.ofColor(PROGRESS).single()
+
+ assertThat(shifted.left).isWithin(0.01f).of(baseline.left)
+ assertThat(shifted.right).isWithin(0.01f).of(baseline.right)
+ assertThat(shifted.top).isWithin(0.01f).of(baseline.top)
+ assertThat(shifted.bottom).isWithin(0.01f).of(baseline.bottom)
+ }
+
+ @Test
+ fun `a shadow does not change the measured size`() {
+ val plain = newBar()
+ val shadowed = newBar { shadowRadius = 8f; shadowDy = 4f }
+
+ val spec = View.MeasureSpec.makeMeasureSpec(2000, View.MeasureSpec.AT_MOST)
+ plain.measure(spec, spec)
+ shadowed.measure(spec, spec)
+
+ assertThat(shadowed.measuredWidth).isEqualTo(plain.measuredWidth)
+ assertThat(shadowed.measuredHeight).isEqualTo(plain.measuredHeight)
+ }
+
+ @Test
+ fun `by default both on and off segments cast the shadow`() {
+ val bar = newBar()
+
+ assertThat(bar.shadowTarget).isEqualTo(ShadowTarget.ALL)
+ }
+
+ @Test
+ fun `the shadow target selects which paints carry a shadow layer`() {
+ // Asserted through layerType, which is the observable consequence: the
+ // view only needs a software layer while something is casting a shadow.
+ val bar = newBar { shadowRadius = 6f }
+ assertThat(bar.layerType).isEqualTo(View.LAYER_TYPE_SOFTWARE)
+
+ bar.shadowTarget = ShadowTarget.ON_SEGMENTS
+ assertThat(bar.layerType).isEqualTo(View.LAYER_TYPE_SOFTWARE)
+
+ bar.shadowTarget = ShadowTarget.OFF_SEGMENTS
+ assertThat(bar.layerType).isEqualTo(View.LAYER_TYPE_SOFTWARE)
+
+ bar.shadowRadius = 0f
+ assertThat(bar.layerType).isEqualTo(View.LAYER_TYPE_NONE)
+ }
+
+ @Test
+ fun `changing the shadow target requests a repaint`() {
+ val bar = newBar { shadowRadius = 6f }
+ shadowOf(bar).clearWasInvalidated()
+
+ bar.shadowTarget = ShadowTarget.ON_SEGMENTS
+
+ assertThat(shadowOf(bar).wasInvalidated()).isTrue()
+ }
+
+ @Test
+ fun `the view supplies a rounded outline matching the track for elevation`() {
+ val bar = newBar {
+ cornerRadius = 9f
+ inactiveHeightRatio = 0.5f
+ }
+ render(bar)
+
+ val outline = android.graphics.Outline()
+ bar.outlineProvider.getOutline(bar, outline)
+
+ assertThat(outline.isEmpty).isFalse()
+ val rect = android.graphics.Rect()
+ assertThat(outline.getRect(rect)).isTrue()
+ // The 20px-tall centred track band, not the full 40px view.
+ assertThat(rect.top).isEqualTo(10)
+ assertThat(rect.bottom).isEqualTo(30)
+ assertThat(rect.left).isEqualTo(0)
+ assertThat(rect.right).isEqualTo(WIDTH)
+ assertThat(outline.radius).isWithin(0.01f).of(9f)
+ }
+
+ // endregion
+
+ // region 5. animation
+
+ @Test
+ fun `animation is off by default`() {
+ val bar = newBar()
+
+ assertThat(bar.segmentAnimation).isEqualTo(SegmentAnimation.NONE)
+ assertThat(bar.animationDurationMs)
+ .isEqualTo(SegmentedProgressBar.DEFAULT_ANIMATION_DURATION_MS)
+ }
+
+ @Test
+ fun `with animation off a toggled segment is drawn fully at once`() {
+ val bar = newBar()
+ render(bar)
+
+ bar.toggleDivision(1)
+ val segment = render(bar).ops.ofColor(PROGRESS).single()
+
+ assertThat(segment.width).isWithin(0.01f).of(75f)
+ assertThat(segment.alphaFraction).isWithin(0.01f).of(1f)
+ }
+
+ @Test
+ fun `the initial state does not animate in`() {
+ // Otherwise every screen visibly assembles itself on first appearance.
+ val bar = newBar {
+ segmentAnimation = SegmentAnimation.FADE
+ enabledDivisions = listOf(0, 1)
+ }
+
+ val segments = render(bar).ops.ofRgb(PROGRESS)
+
+ assertThat(segments).hasSize(2)
+ segments.forEach { assertThat(it.alphaFraction).isWithin(0.01f).of(1f) }
+ }
+
+ @Test
+ fun `fade ramps a segment's alpha up over the duration`() {
+ val bar = newBar {
+ segmentAnimation = SegmentAnimation.FADE
+ animationDurationMs = 200L
+ }
+ render(bar) // lay out first, so the change afterwards animates
+
+ bar.toggleDivision(1)
+
+ // A segment at zero opacity is not drawn at all, which is why this
+ // samples just after the start rather than exactly at it.
+ advance(20)
+ val nearStart = render(bar).ops.ofRgb(PROGRESS).single()
+ assertThat(nearStart.alphaFraction).isLessThan(0.3f)
+
+ advance(80)
+ val midway = render(bar).ops.ofRgb(PROGRESS).single()
+ assertThat(midway.alphaFraction).isGreaterThan(0.2f)
+ assertThat(midway.alphaFraction).isLessThan(0.9f)
+ // Fading changes opacity, not size.
+ assertThat(midway.width).isWithin(0.01f).of(75f)
+
+ advance(150)
+ val settled = render(bar).ops.ofRgb(PROGRESS).single()
+ assertThat(settled.alphaFraction).isWithin(0.01f).of(1f)
+ }
+
+ @Test
+ fun `grow extends a segment from its leading edge over the duration`() {
+ val bar = newBar {
+ segmentAnimation = SegmentAnimation.GROW
+ animationDurationMs = 200L
+ }
+ render(bar)
+
+ bar.toggleDivision(1)
+
+ advance(100)
+ val midway = render(bar).ops.ofColor(PROGRESS).single()
+ // Segment 1 spans 75..150; it grows rightwards from 75.
+ assertThat(midway.left).isWithin(0.01f).of(75f)
+ assertThat(midway.width).isGreaterThan(0f)
+ assertThat(midway.width).isLessThan(75f)
+ // Growing changes size, not opacity.
+ assertThat(midway.alphaFraction).isWithin(0.01f).of(1f)
+
+ advance(150)
+ val settled = render(bar).ops.ofColor(PROGRESS).single()
+ assertThat(settled.left).isWithin(0.01f).of(75f)
+ assertThat(settled.right).isWithin(0.01f).of(150f)
+ }
+
+ @Test
+ fun `a segment animates back out when cleared`() {
+ val bar = newBar {
+ segmentAnimation = SegmentAnimation.FADE
+ animationDurationMs = 200L
+ enabledDivisions = listOf(1)
+ }
+ render(bar)
+
+ bar.disableDivision(1)
+
+ // Still drawn while it fades away.
+ advance(100)
+ val fading = render(bar).ops.ofRgb(PROGRESS)
+ assertThat(fading).hasSize(1)
+ assertThat(fading.single().alphaFraction).isLessThan(0.9f)
+
+ advance(150)
+ assertThat(render(bar).ops.ofRgb(PROGRESS)).isEmpty()
+ }
+
+ @Test
+ fun `an interrupted transition continues from where it had got to`() {
+ // Toggling twice in quick succession must not snap back to the start.
+ val bar = newBar {
+ segmentAnimation = SegmentAnimation.FADE
+ animationDurationMs = 200L
+ }
+ render(bar)
+
+ bar.toggleDivision(1)
+ advance(100)
+ val partway = render(bar).ops.ofRgb(PROGRESS).single().alphaFraction
+
+ // Reverse direction mid-flight.
+ bar.toggleDivision(1)
+ val justAfterReversal = render(bar).ops.ofRgb(PROGRESS).single().alphaFraction
+
+ assertThat(partway).isGreaterThan(0.2f)
+ // It resumes from roughly where it was rather than jumping to full.
+ assertThat(justAfterReversal).isWithin(0.05f).of(partway)
+ }
+
+ @Test
+ fun `a zero duration disables animation`() {
+ val bar = newBar {
+ segmentAnimation = SegmentAnimation.FADE
+ animationDurationMs = 0L
+ }
+ render(bar)
+
+ bar.toggleDivision(1)
+
+ assertThat(render(bar).ops.ofRgb(PROGRESS).single().alphaFraction)
+ .isWithin(0.01f).of(1f)
+ }
+
+ @Test
+ fun `switching animation off mid-flight snaps to the final state`() {
+ val bar = newBar {
+ segmentAnimation = SegmentAnimation.FADE
+ animationDurationMs = 200L
+ }
+ render(bar)
+ bar.toggleDivision(1)
+ advance(50)
+
+ bar.segmentAnimation = SegmentAnimation.NONE
+
+ assertThat(render(bar).ops.ofRgb(PROGRESS).single().alphaFraction)
+ .isWithin(0.01f).of(1f)
+ }
+
+ @Test
+ fun `a negative animation duration is rejected`() {
+ val bar = newBar()
+
+ val error = runCatching { bar.animationDurationMs = -1L }.exceptionOrNull()
+
+ assertThat(error).isInstanceOf(IllegalArgumentException::class.java)
+ }
+
+ @Test
+ fun `animating one segment leaves the others untouched`() {
+ val bar = newBar {
+ segmentAnimation = SegmentAnimation.FADE
+ animationDurationMs = 200L
+ enabledDivisions = listOf(0)
+ }
+ render(bar)
+
+ bar.toggleDivision(2)
+ advance(100)
+
+ val segments = render(bar).ops.ofRgb(PROGRESS).sortedBy { it.left }
+ assertThat(segments).hasSize(2)
+ // The already-lit segment is still fully opaque.
+ assertThat(segments[0].alphaFraction).isWithin(0.01f).of(1f)
+ assertThat(segments[1].alphaFraction).isLessThan(0.9f)
+ }
+
+ @Test
+ fun `growing under rtl extends from the right hand edge`() {
+ val bar = newBar {
+ segmentAnimation = SegmentAnimation.GROW
+ animationDurationMs = 200L
+ }
+ // Force RTL the same way the drawing tests do.
+ context.applicationInfo.flags =
+ context.applicationInfo.flags or android.content.pm.ApplicationInfo.FLAG_SUPPORTS_RTL
+ bar.layoutDirection = View.LAYOUT_DIRECTION_RTL
+ render(bar)
+ assertThat(bar.layoutDirection).isEqualTo(View.LAYOUT_DIRECTION_RTL)
+
+ bar.toggleDivision(0)
+ advance(100)
+
+ val segment = render(bar).ops.ofColor(PROGRESS).single()
+ // Segment 0 sits at 225..300 under RTL and grows leftwards from 300.
+ assertThat(segment.right).isWithin(0.01f).of(300f)
+ assertThat(segment.width).isLessThan(75f)
+ assertThat(segment.width).isGreaterThan(0f)
+ }
+
+ // endregion
+
+ // region gaps
+
+ @Test
+ fun `a transparent divider leaves a real gap rather than a painted line`() {
+ val bar = newBar {
+ dividerWidth = 12f
+ dividerColor = Color.TRANSPARENT
+ enabledDivisions = listOf(0, 1, 2, 3)
+ }
+
+ val canvas = render(bar)
+
+ // Nothing is painted in the gap at all, not a transparent rectangle, and
+ // not a strip of track showing through.
+ assertThat(canvas.ops.ofRgb(Color.TRANSPARENT).filter { it.color == Color.TRANSPARENT })
+ .isEmpty()
+ val segments = canvas.ops.ofColor(PROGRESS).sortedBy { it.left }
+ assertThat(segments).hasSize(4)
+ // 6px of clear space either side of each interior boundary.
+ assertThat(segments[0].right).isWithin(0.01f).of(69f)
+ assertThat(segments[1].left).isWithin(0.01f).of(81f)
+ assertThat(segments[1].left - segments[0].right).isWithin(0.01f).of(12f)
+ }
+
+ @Test
+ fun `the track is absent in the gap too`() {
+ val bar = newBar {
+ dividerWidth = 12f
+ dividerColor = Color.TRANSPARENT
+ }
+
+ val cells = render(bar).ops.ofColor(TRACK).sortedBy { it.left }
+
+ assertThat(cells).hasSize(DIVISIONS)
+ assertThat(cells[0].right).isWithin(0.01f).of(69f)
+ assertThat(cells[1].left).isWithin(0.01f).of(81f)
+ }
+
+ @Test
+ fun `a zero gap makes segments contiguous`() {
+ val bar = newBar {
+ dividerWidth = 0f
+ enabledDivisions = listOf(0, 1)
+ }
+
+ val segments = render(bar).ops.ofColor(PROGRESS).sortedBy { it.left }
+
+ assertThat(segments[0].right).isWithin(0.01f).of(segments[1].left)
+ }
+
+ @Test
+ fun `an opaque divider is still painted over the gap`() {
+ // The legacy look: a visible line between cells.
+ val bar = newBar {
+ dividerWidth = 8f
+ dividerColor = Color.BLUE
+ }
+
+ val dividers = render(bar).ops.ofColor(Color.BLUE)
+
+ assertThat(dividers).hasSize(DIVISIONS - 1)
+ }
+
+ // endregion
+
+ // region size constraints
+
+ @Test
+ fun `maxWidth caps a match_parent bar`() {
+ val bar = newBar { maxWidth = 200 }
+
+ bar.measure(
+ View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(40, View.MeasureSpec.EXACTLY),
+ )
+
+ assertThat(bar.measuredWidth).isEqualTo(200)
+ }
+
+ @Test
+ fun `maxHeight caps the measured height`() {
+ val bar = newBar { maxHeight = 12 }
+
+ bar.measure(
+ View.MeasureSpec.makeMeasureSpec(300, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY),
+ )
+
+ assertThat(bar.measuredHeight).isEqualTo(12)
+ }
+
+ @Test
+ fun `an unset maximum leaves measurement alone`() {
+ val bar = newBar()
+
+ assertThat(bar.maxWidth).isEqualTo(SegmentedProgressBar.NO_MAX_SIZE)
+ assertThat(bar.maxHeight).isEqualTo(SegmentedProgressBar.NO_MAX_SIZE)
+
+ bar.measure(
+ View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.EXACTLY),
+ )
+
+ assertThat(bar.measuredWidth).isEqualTo(1000)
+ assertThat(bar.measuredHeight).isEqualTo(400)
+ }
+
+ @Test
+ fun `a minimum wins over a smaller maximum`() {
+ // The framework treats a minimum as the harder constraint; matching that
+ // avoids a view that measures smaller than it declared it can be.
+ val bar = newBar {
+ minimumWidth = 250
+ maxWidth = 100
+ }
+
+ bar.measure(
+ View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(40, View.MeasureSpec.EXACTLY),
+ )
+
+ assertThat(bar.measuredWidth).isEqualTo(250)
+ }
+
+ @Test
+ fun `a negative maximum is rejected but the sentinel is accepted`() {
+ val bar = newBar()
+
+ assertThat(runCatching { bar.maxWidth = -5 }.exceptionOrNull())
+ .isInstanceOf(IllegalArgumentException::class.java)
+ bar.maxWidth = SegmentedProgressBar.NO_MAX_SIZE
+ assertThat(bar.maxWidth).isEqualTo(SegmentedProgressBar.NO_MAX_SIZE)
+ }
+
+ // endregion
+
+ // region entry animation
+
+ /** Lays the bar out, which is what triggers the entry animation. */
+ private fun layOut(bar: SegmentedProgressBar) = render(bar)
+
+ @Test
+ fun `no entry animation means the bar arrives fully formed`() {
+ val bar = newBar { enabledDivisions = listOf(0, 1) }
+
+ val segments = layOut(bar).ops.ofRgb(PROGRESS)
+
+ assertThat(segments).hasSize(2)
+ segments.forEach { assertThat(it.alphaFraction).isWithin(0.01f).of(1f) }
+ }
+
+ @Test
+ fun `a fade entry animates the initial state in`() {
+ // Note this happens even though segmentAnimation is NONE: entry and
+ // change animations are independent opt-ins.
+ val bar = newBar {
+ entryAnimation = EntryAnimation.FADE
+ animationDurationMs = 200L
+ enabledDivisions = listOf(0, 1)
+ }
+
+ layOut(bar)
+ advance(20)
+ val early = render(bar).ops.ofRgb(PROGRESS)
+ assertThat(early).hasSize(2)
+ early.forEach { assertThat(it.alphaFraction).isLessThan(0.3f) }
+
+ advance(250)
+ render(bar).ops.ofRgb(PROGRESS).forEach {
+ assertThat(it.alphaFraction).isWithin(0.01f).of(1f)
+ }
+ }
+
+ @Test
+ fun `a grow entry extends the initial segments`() {
+ val bar = newBar {
+ entryAnimation = EntryAnimation.GROW
+ animationDurationMs = 200L
+ enabledDivisions = listOf(1)
+ }
+
+ layOut(bar)
+ advance(100)
+
+ val segment = render(bar).ops.ofColor(PROGRESS).single()
+ assertThat(segment.left).isWithin(0.01f).of(75f)
+ assertThat(segment.width).isLessThan(75f)
+ assertThat(segment.width).isGreaterThan(0f)
+ }
+
+ @Test
+ fun `a staggered entry reveals segments in order`() {
+ val bar = newBar {
+ entryAnimation = EntryAnimation.STAGGER
+ animationDurationMs = 100L
+ entryStaggerDelayMs = 100L
+ enabledDivisions = listOf(0, 1, 2)
+ }
+
+ layOut(bar)
+
+ // First segment is under way while the later ones have not started.
+ advance(50)
+ assertThat(render(bar).ops.ofRgb(PROGRESS)).hasSize(1)
+
+ // Second joins in.
+ advance(100)
+ assertThat(render(bar).ops.ofRgb(PROGRESS)).hasSize(2)
+
+ // Everything has arrived.
+ advance(300)
+ val settled = render(bar).ops.ofRgb(PROGRESS)
+ assertThat(settled).hasSize(3)
+ settled.forEach { assertThat(it.alphaFraction).isWithin(0.01f).of(1f) }
+ }
+
+ @Test
+ fun `the entry animation runs only once`() {
+ val bar = newBar {
+ entryAnimation = EntryAnimation.FADE
+ animationDurationMs = 200L
+ enabledDivisions = listOf(0)
+ }
+ layOut(bar)
+ advance(300)
+
+ // A second layout pass, as a scroll or re-measure would cause.
+ val canvas = render(bar)
+
+ assertThat(canvas.ops.ofRgb(PROGRESS).single().alphaFraction).isWithin(0.01f).of(1f)
+ }
+
+ @Test
+ fun `a change after entry uses the change animation, not the entry one`() {
+ val bar = newBar {
+ entryAnimation = EntryAnimation.FADE
+ segmentAnimation = SegmentAnimation.NONE
+ animationDurationMs = 200L
+ enabledDivisions = listOf(0)
+ }
+ layOut(bar)
+ advance(300)
+
+ bar.toggleDivision(2)
+
+ // segmentAnimation is NONE, so the new segment appears immediately.
+ val segments = render(bar).ops.ofRgb(PROGRESS).sortedBy { it.left }
+ assertThat(segments).hasSize(2)
+ segments.forEach { assertThat(it.alphaFraction).isWithin(0.01f).of(1f) }
+ }
+
+ @Test
+ fun `a negative stagger delay is rejected`() {
+ val bar = newBar()
+
+ assertThat(runCatching { bar.entryStaggerDelayMs = -1L }.exceptionOrNull())
+ .isInstanceOf(IllegalArgumentException::class.java)
+ }
+
+ // endregion
+
+ // region recurring animation
+
+ @Test
+ fun `recurring animation is off by default`() {
+ val bar = newBar()
+
+ assertThat(bar.recurringAnimation).isEqualTo(RecurringAnimation.NONE)
+ assertThat(bar.recurringDurationMs)
+ .isEqualTo(SegmentedProgressBar.DEFAULT_RECURRING_DURATION_MS)
+ }
+
+ @Test
+ fun `a pulse varies the lit segments' alpha over time`() {
+ val bar = attachedBar {
+ recurringAnimation = RecurringAnimation.PULSE
+ recurringDurationMs = 400L
+ enabledDivisions = listOf(1)
+ }
+
+ val samples = (0 until 4).map {
+ advance(100)
+ renderToRecordingCanvas(bar, WIDTH, HEIGHT).ops.ofRgb(PROGRESS).single().alphaFraction
+ }
+
+ // It moves, and it never disappears entirely.
+ assertThat(samples.distinct().size).isGreaterThan(1)
+ samples.forEach { assertThat(it).isGreaterThan(0.3f) }
+ }
+
+ @Test
+ fun `a shimmer tints different segments at different times`() {
+ val bar = attachedBar {
+ recurringAnimation = RecurringAnimation.SHIMMER
+ recurringDurationMs = 800L
+ enabledDivisions = listOf(0, 1, 2, 3)
+ }
+
+ // Filtered by "not the track" rather than by colour: a tinted segment no
+ // longer matches the progress colour exactly, which is the whole point.
+ fun sample() = renderToRecordingCanvas(bar, WIDTH, HEIGHT)
+ .ops.filter { it.color != TRACK }
+ .sortedBy { it.left }
+ .map { it.color }
+
+ advance(100)
+ val early = sample()
+ advance(400)
+ val later = sample()
+
+ assertThat(early).hasSize(4)
+ assertThat(later).hasSize(4)
+ // The sweep moved, so the pattern of brightness across the bar changed.
+ assertThat(early).isNotEqualTo(later)
+ // And at least one segment is genuinely tinted at some point.
+ assertThat((early + later).any { it != PROGRESS }).isTrue()
+ }
+
+ @Test
+ fun `a shimmer only ever lightens towards the shimmer colour`() {
+ val bar = attachedBar {
+ recurringAnimation = RecurringAnimation.SHIMMER
+ recurringDurationMs = 800L
+ enabledDivisions = listOf(0, 1, 2, 3)
+ }
+
+ advance(200)
+ val colors = renderToRecordingCanvas(bar, WIDTH, HEIGHT)
+ .ops.filter { it.color != TRACK }
+ .map { it.color }
+
+ // Base colour is pure red, and the shimmer is white, so tinting can only
+ // raise the green and blue channels and must leave alpha alone.
+ colors.forEach {
+ assertThat(Color.red(it)).isEqualTo(255)
+ assertThat(Color.green(it)).isAtLeast(0)
+ assertThat(Color.green(it)).isEqualTo(Color.blue(it))
+ assertThat(Color.alpha(it)).isEqualTo(255)
+ }
+ }
+
+ @Test
+ fun `a detached bar runs no recurring animation`() {
+ // The loop is driven by postInvalidateOnAnimation, which does nothing
+ // while detached; the colour must stay put rather than freezing mid-tint.
+ val bar = newBar {
+ recurringAnimation = RecurringAnimation.SHIMMER
+ enabledDivisions = listOf(1)
+ }
+
+ advance(200)
+ val a = render(bar).ops.ofColor(PROGRESS).single().color
+ advance(200)
+ val b = render(bar).ops.ofColor(PROGRESS).single().color
+
+ assertThat(a).isEqualTo(PROGRESS)
+ assertThat(b).isEqualTo(PROGRESS)
+ }
+
+ @Test
+ fun `turning the recurring animation off restores the plain colour`() {
+ val bar = attachedBar {
+ recurringAnimation = RecurringAnimation.SHIMMER
+ enabledDivisions = listOf(0, 1, 2, 3)
+ }
+ advance(200)
+
+ bar.recurringAnimation = RecurringAnimation.NONE
+
+ renderToRecordingCanvas(bar, WIDTH, HEIGHT).ops.ofRgb(PROGRESS).forEach {
+ assertThat(it.color).isEqualTo(PROGRESS)
+ }
+ }
+
+ @Test
+ fun `a non-positive recurring duration is rejected`() {
+ val bar = newBar()
+
+ assertThat(runCatching { bar.recurringDurationMs = 0L }.exceptionOrNull())
+ .isInstanceOf(IllegalArgumentException::class.java)
+ }
+
+ /** A bar attached to an activity, which the recurring loop requires. */
+ private fun attachedBar(configure: SegmentedProgressBar.() -> Unit): SegmentedProgressBar {
+ val activity = org.robolectric.Robolectric
+ .buildActivity(android.app.Activity::class.java).setup().get()
+ val bar = SegmentedProgressBar(activity).apply {
+ divisions = DIVISIONS
+ dividerWidth = 0f
+ cornerRadius = 0f
+ progressBarColor = PROGRESS
+ progressBarBackgroundColor = TRACK
+ configure()
+ }
+ activity.setContentView(bar, android.view.ViewGroup.LayoutParams(WIDTH, HEIGHT))
+ shadowOf(Looper.getMainLooper()).idle()
+ return bar
+ }
+
+ // endregion
+}
diff --git a/segmented/src/test/resources/robolectric.properties b/segmented/src/test/resources/robolectric.properties
new file mode 100644
index 0000000..3fe63e1
--- /dev/null
+++ b/segmented/src/test/resources/robolectric.properties
@@ -0,0 +1,12 @@
+# The Robolectric sandbox SDK is pinned rather than following compileSdk (37).
+#
+# Two separate ceilings apply:
+# * Robolectric ships a pre-built android-all jar per API level and lags the
+# newest platform release.
+# * Robolectric's API 36+ sandboxes require Java 21 to run; this project builds
+# on the Java 17 toolchain, so 35 is the newest level available here.
+#
+# The library's minSdk is 26 and it touches no API newer than that, so testing
+# against 35 exercises the same code paths. Raise this after moving the build to
+# a Java 21+ toolchain.
+sdk=35
diff --git a/settings.gradle b/settings.gradle
deleted file mode 100644
index f708972..0000000
--- a/settings.gradle
+++ /dev/null
@@ -1 +0,0 @@
-include ':app', ':segmented'
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..7bb4e17
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,30 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+
+dependencyResolutionManagement {
+ repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "SegmentedProgressBar"
+
+include(":segmented")
+include(":segmented-compose")
+include(":app")