diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f481ad --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,81 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v5 + + # Catches the class of breakage that left master unbuildable between 1.6 and 2.0.0: + # assemble every module, then run lint and the unit tests. + - name: Assemble + run: ./gradlew assembleDebug assembleRelease --stacktrace + + - name: Lint + run: ./gradlew lint --stacktrace + + - name: Unit tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Screenshot tests + run: ./gradlew verifyRoborazziDebug --stacktrace + + # Roborazzi goldens are recorded on the maintainer's machine; if the Linux renderer + # disagrees, the diffs are the fastest way to see whether it is a real regression. + - name: Upload screenshot diffs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: roborazzi-diffs + path: | + **/build/outputs/roborazzi/** + if-no-files-found: ignore + retention-days: 7 + + - name: Upload lint reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: lint-reports + path: '**/build/reports/lint-results-*.html' + if-no-files-found: ignore + retention-days: 7 + + publish-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - uses: gradle/actions/setup-gradle@v5 + + # JitPack builds tags with -Pgroup/-Pversion injected. Exercising the same path here means a + # broken release is caught before the tag is cut, not after. + - name: Publish to Maven local as JitPack would + run: | + ./gradlew publishToMavenLocal \ + -Pgroup=com.github.zjywill -Pversion=0.0.0-ci --stacktrace + + - name: Show published artifacts + run: find ~/.m2/repository/com/github/zjywill -type f | sort diff --git a/README.md b/README.md index ce62672..95bc0a3 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,37 @@ dependencies { } ``` +Check the [JitPack page](https://jitpack.io/#zjywill/OverwatchProgress) for the coordinates of the +newest release: `2.1.0` adds a second artifact for the Compose API, and JitPack names artifacts +differently once a repository publishes more than one module. + > The coordinate is unchanged from `1.6`, so upgrading is a version bump. `2.0.0` requires JDK 17 > and raises `minSdk` to 24. +# Compose + +```kotlin +HiveProgress( + modifier = Modifier.size(160.dp), + colors = HiveProgressDefaults.Rainbow, + spacing = 8.dp, + cornerRadius = 6.dp, + shrink = true, +) +``` + +There is also a stateless overload that takes the wave position instead of animating itself, which +is what makes the comb a pure function of its arguments — useful for screenshot tests, or for +driving the wave from your own animation: + +```kotlin +HiveProgress(progress = 420f, modifier = Modifier.size(160.dp)) +``` + +Both renderers share [`HiveGeometry`](overwatch/src/main/java/com/comix/overwatch/HiveGeometry.java), +so the comb layout and the fade wave cannot drift apart between them. The Compose version reports +itself as an indeterminate progress indicator to accessibility services. + # Spacing `hive_spacing` is the gap between two neighbouring hexagons, and it applies uniformly in all six diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f803911..0f1eb13 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) } android { @@ -14,6 +15,10 @@ android { versionName = "1.0" } + buildFeatures { + compose = true + } + buildTypes { release { isMinifyEnabled = false @@ -29,4 +34,13 @@ android { dependencies { implementation(project(":overwatch")) + implementation(project(":overwatch-compose")) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.activity.compose) + debugImplementation(libs.compose.ui.tooling) } diff --git a/app/src/main/java/com/comix/demo/MainActivity.java b/app/src/main/java/com/comix/demo/MainActivity.java deleted file mode 100644 index 3635e74..0000000 --- a/app/src/main/java/com/comix/demo/MainActivity.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.comix.demo; - -import android.app.Activity; -import android.os.Bundle; -import android.widget.SeekBar; -import android.widget.TextView; - -import com.comix.overwatch.HiveProgressView; - -import java.util.Locale; - -public class MainActivity extends Activity { - - private static final int INITIAL_SPACING_DP = 8; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - final HiveProgressView progressView = findViewById(R.id.hive_progress); - final TextView label = findViewById(R.id.spacing_label); - SeekBar bar = findViewById(R.id.spacing_bar); - - bar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { - @Override - public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { - progressView.setSpacingDp(progress); - label.setText(String.format(Locale.US, "Spacing %ddp", progress)); - } - - @Override - public void onStartTrackingTouch(SeekBar seekBar) { - } - - @Override - public void onStopTrackingTouch(SeekBar seekBar) { - } - }); - - bar.setProgress(INITIAL_SPACING_DP); - progressView.setSpacingDp(INITIAL_SPACING_DP); - label.setText(String.format(Locale.US, "Spacing %ddp", INITIAL_SPACING_DP)); - } -} diff --git a/app/src/main/java/com/comix/demo/MainActivity.kt b/app/src/main/java/com/comix/demo/MainActivity.kt new file mode 100644 index 0000000..2bdef7d --- /dev/null +++ b/app/src/main/java/com/comix/demo/MainActivity.kt @@ -0,0 +1,104 @@ +package com.comix.demo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.comix.overwatch.HiveProgressView +import com.comix.overwatch.compose.HiveProgress +import com.comix.overwatch.compose.HiveProgressDefaults +import kotlin.math.roundToInt + +private const val ANIMATION_MILLIS = 5000 +private val CORNER_RADIUS = 8.dp +private const val MAX_SPACING_DP = 48f + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + MaterialTheme { + Surface(Modifier.fillMaxSize()) { DemoScreen() } + } + } + } +} + +/** Drives both renderers from one slider, so any drift between them is visible side by side. */ +@Composable +private fun DemoScreen() { + var spacingDp by remember { mutableFloatStateOf(8f) } + // HiveProgressView still takes a raw pixel corner radius, so convert to keep both sides equal. + val cornerRadiusPx = with(LocalDensity.current) { CORNER_RADIUS.roundToPx() } + + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + ) { + Text("Compose", style = MaterialTheme.typography.labelLarge) + HiveProgress( + modifier = Modifier.size(170.dp), + colors = HiveProgressDefaults.Rainbow, + spacing = spacingDp.dp, + cornerRadius = CORNER_RADIUS, + shrink = true, + maxAlpha = 1f, + animationDurationMillis = ANIMATION_MILLIS, + ) + + Text("View", style = MaterialTheme.typography.labelLarge) + AndroidView( + factory = { context -> + HiveProgressView(context).apply { + isRainbow = true + isShrink = true + cornerRadius = cornerRadiusPx + maxAlpha = 255 + animationTime = ANIMATION_MILLIS + } + }, + update = { view -> view.setSpacingDp(spacingDp) }, + modifier = Modifier.size(170.dp), + ) + + Text("Spacing ${spacingDp.roundToInt()}dp") + Slider( + value = spacingDp, + onValueChange = { spacingDp = it }, + valueRange = 0f..MAX_SPACING_DP, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DemoScreenPreview() { + MaterialTheme { DemoScreen() } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index bc5a308..0000000 --- a/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - diff --git a/build.gradle.kts b/build.gradle.kts index 549000a..0244cef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false + alias(libs.plugins.kotlin.compose) apply false + alias(libs.plugins.roborazzi) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 504a686..9f05b71 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,37 @@ [versions] agp = "9.3.1" +# Must match the kotlin-gradle-plugin AGP bundles (9.3.1 -> 2.2.10). +kotlin = "2.2.10" +composeBom = "2026.06.01" +activityCompose = "1.13.0" compileSdk = "36" targetSdk = "36" minSdk = "24" +junit = "4.13.2" +robolectric = "4.16.1" +androidxTestJunit = "1.3.0" +roborazzi = "1.71.0" + +[libraries] +compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +compose-ui = { group = "androidx.compose.ui", name = "ui" } +compose-material3 = { group = "androidx.compose.material3", name = "material3" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } + +junit = { group = "junit", name = "junit", version.ref = "junit" } +androidx-test-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestJunit" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } +roborazzi-compose = { group = "io.github.takahirom.roborazzi", name = "roborazzi-compose", version.ref = "roborazzi" } +roborazzi-junit-rule = { group = "io.github.takahirom.roborazzi", name = "roborazzi-junit-rule", version.ref = "roborazzi" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } diff --git a/overwatch-compose/build.gradle.kts b/overwatch-compose/build.gradle.kts new file mode 100644 index 0000000..063dd38 --- /dev/null +++ b/overwatch-compose/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.roborazzi) + `maven-publish` +} + +android { + namespace = "com.comix.overwatch.compose" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + + buildFeatures { + compose = true + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } + + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} + +// JitPack injects -Pgroup and -Pversion; these are only the local fallbacks. +if (group.toString().isBlank() || group.toString() == rootProject.name) { + group = "com.github.zjywill" +} +if (version.toString() == Project.DEFAULT_VERSION) { + version = "2.1.0" +} + +publishing { + publications { + register("release") { + afterEvaluate { from(components["release"]) } + } + } +} + +dependencies { + // api: HiveGeometry is part of this module's surface, and callers may want the View too. + api(project(":overwatch")) + + implementation(platform(libs.compose.bom)) + api(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.test.junit) + testImplementation(platform(libs.compose.bom)) + testImplementation(libs.compose.ui.test.junit4) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit.rule) + debugImplementation(libs.compose.ui.test.manifest) +} diff --git a/overwatch-compose/proguard-rules.pro b/overwatch-compose/proguard-rules.pro new file mode 100644 index 0000000..fb164d6 --- /dev/null +++ b/overwatch-compose/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/overwatch-compose/src/main/java/com/comix/overwatch/compose/HiveProgress.kt b/overwatch-compose/src/main/java/com/comix/overwatch/compose/HiveProgress.kt new file mode 100644 index 0000000..ff57a19 --- /dev/null +++ b/overwatch-compose/src/main/java/com/comix/overwatch/compose/HiveProgress.kt @@ -0,0 +1,220 @@ +package com.comix.overwatch.compose + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.comix.overwatch.HiveGeometry +import kotlin.math.hypot +import kotlin.math.min + +/** Defaults shared by the [HiveProgress] overloads. */ +object HiveProgressDefaults { + + /** Size the indicator falls back to when the caller does not constrain it. */ + val Size: Dp = 96.dp + + /** Matches the View's default of 70 out of 255. */ + const val MaxAlpha: Float = 70f / 255f + + const val AnimationDurationMillis: Int = 2000 + + /** The seven-colour ramp used by the View's `hive_rainbow` mode. */ + val Rainbow: List = listOf( + Color(0xFFFF0000), + Color(0xFFFF7F00), + Color(0xFFFFFF00), + Color(0xFF00FF00), + Color(0xFF0000FF), + Color(0xFF4B0082), + Color(0xFF9400D3), + ) +} + +/** + * An indeterminate loading indicator: seven hexagons in a 2-3-2 comb that fade in and out as a + * wave. + * + * The comb is laid out by [HiveGeometry], the same code the `HiveProgressView` uses, so both + * renderers stay in step. + * + * @param color colour of every hexagon; ignored when [colors] is supplied + * @param colors per-cell colour ramp, e.g. [HiveProgressDefaults.Rainbow] + * @param spacing gap between neighbouring hexagons; [Dp.Unspecified] derives it from the size + * @param cornerRadius how far each hexagon corner is rounded + * @param shrink scale each hexagon with its opacity instead of only fading it + * @param maxAlpha peak opacity of a hexagon, in `[0, 1]` + */ +@Composable +fun HiveProgress( + modifier: Modifier = Modifier, + color: Color = Color.Black, + colors: List? = null, + spacing: Dp = Dp.Unspecified, + cornerRadius: Dp = 0.dp, + shrink: Boolean = false, + maxAlpha: Float = HiveProgressDefaults.MaxAlpha, + animationDurationMillis: Int = HiveProgressDefaults.AnimationDurationMillis, +) { + val transition = rememberInfiniteTransition(label = "HiveProgress") + val progress by transition.animateFloat( + initialValue = 0f, + targetValue = HiveGeometry.MAX_PROGRESS.toFloat(), + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = animationDurationMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "progress", + ) + HiveProgress( + progress = progress, + modifier = modifier.progressSemantics(), + color = color, + colors = colors, + spacing = spacing, + cornerRadius = cornerRadius, + shrink = shrink, + maxAlpha = maxAlpha, + ) +} + +/** + * The stateless form of [HiveProgress]: the caller drives the wave, which makes the output a pure + * function of [progress] and so keeps screenshot tests deterministic. + * + * @param progress position in the wave, from 0 to [HiveGeometry.MAX_PROGRESS] + */ +@Composable +fun HiveProgress( + progress: Float, + modifier: Modifier = Modifier, + color: Color = Color.Black, + colors: List? = null, + spacing: Dp = Dp.Unspecified, + cornerRadius: Dp = 0.dp, + shrink: Boolean = false, + maxAlpha: Float = HiveProgressDefaults.MaxAlpha, +) { + val path = remember { Path() } + val vertices = remember { FloatArray(12) } + + Canvas(modifier.defaultMinSize(HiveProgressDefaults.Size, HiveProgressDefaults.Size)) { + val side = min(size.width, size.height) + if (side <= 0f) return@Canvas + + val gap = if (spacing == Dp.Unspecified) HiveGeometry.autoSpacing(side, side) else spacing.toPx() + val radius = HiveGeometry.radiusFor(side, side, gap) + if (radius <= 0f) return@Canvas + + val originX = size.width / 2f + val originY = size.height / 2f + val corner = cornerRadius.toPx() + + for (i in 0 until HiveGeometry.HEX_COUNT) { + val wave = HiveGeometry.waveOrder(i) + val fraction = HiveGeometry.alphaFractionAt(wave, progress) + if (fraction <= 0f) continue + + path.buildHexagon( + cx = HiveGeometry.cellCenterX(i, originX, radius, gap), + cy = HiveGeometry.cellCenterY(i, originY, radius, gap), + r = if (shrink) radius * fraction else radius, + corner = corner, + vertices = vertices, + ) + drawPath( + path = path, + color = colors?.get((wave - 1) % colors.size) ?: color, + alpha = fraction * maxAlpha, + ) + } + } +} + +/** + * Rewrites [this] as a pointy-top hexagon with rounded corners. Each corner is trimmed back along + * both adjacent edges and bridged with a quadratic through the original vertex, which is what + * `CornerPathEffect` does for the View. + */ +private fun Path.buildHexagon( + cx: Float, + cy: Float, + r: Float, + corner: Float, + vertices: FloatArray, +) { + HiveGeometry.hexagonVertices(cx, cy, r, vertices) + reset() + + if (corner <= 0f) { + moveTo(vertices[0], vertices[1]) + for (i in 1 until 6) { + lineTo(vertices[i * 2], vertices[i * 2 + 1]) + } + close() + return + } + + for (i in 0 until 6) { + val vx = vertices[i * 2] + val vy = vertices[i * 2 + 1] + val px = vertices[(i + 5) % 6 * 2] + val py = vertices[(i + 5) % 6 * 2 + 1] + val nx = vertices[(i + 1) % 6 * 2] + val ny = vertices[(i + 1) % 6 * 2 + 1] + + val inLength = hypot(px - vx, py - vy) + val outLength = hypot(nx - vx, ny - vy) + if (inLength <= 0f || outLength <= 0f) continue + + val inT = min(corner, inLength / 2f) / inLength + val outT = min(corner, outLength / 2f) / outLength + + val startX = vx + (px - vx) * inT + val startY = vy + (py - vy) * inT + val endX = vx + (nx - vx) * outT + val endY = vy + (ny - vy) * outT + + if (i == 0) moveTo(startX, startY) else lineTo(startX, startY) + quadraticTo(vx, vy, endX, endY) + } + close() +} + +@Preview(name = "Rainbow", showBackground = true) +@Composable +private fun HiveProgressRainbowPreview() { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(160.dp), + colors = HiveProgressDefaults.Rainbow, + cornerRadius = 6.dp, + maxAlpha = 1f, + ) +} + +@Preview(name = "Wide spacing", showBackground = true) +@Composable +private fun HiveProgressSpacingPreview() { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(160.dp), + spacing = 16.dp, + maxAlpha = 1f, + ) +} diff --git a/overwatch-compose/src/test/java/com/comix/overwatch/compose/HiveProgressScreenshotTest.kt b/overwatch-compose/src/test/java/com/comix/overwatch/compose/HiveProgressScreenshotTest.kt new file mode 100644 index 0000000..e265883 --- /dev/null +++ b/overwatch-compose/src/test/java/com/comix/overwatch/compose/HiveProgressScreenshotTest.kt @@ -0,0 +1,104 @@ +package com.comix.overwatch.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.comix.overwatch.HiveGeometry +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Renders the comb at fixed points in the wave. Every case uses the stateless [HiveProgress] + * overload, so the output depends only on its arguments and never on wall-clock timing. + */ +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "w220dp-h220dp-xhdpi") +class HiveProgressScreenshotTest { + + @get:Rule + val compose = createComposeRule() + + @Test + fun rainbowFullyVisible() = capture("rainbow-fully-visible") { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(200.dp), + colors = HiveProgressDefaults.Rainbow, + maxAlpha = 1f, + ) + } + + /** At zero spacing the hexagons must tile with no seam and no overlap. */ + @Test + fun zeroSpacingTiles() = capture("zero-spacing") { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(200.dp), + spacing = 0.dp, + color = Color.Black, + maxAlpha = 1f, + ) + } + + @Test + fun wideSpacingShrinksHexagons() = capture("wide-spacing") { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(200.dp), + spacing = 24.dp, + color = Color.Black, + maxAlpha = 1f, + ) + } + + @Test + fun roundedCorners() = capture("rounded-corners") { + HiveProgress( + progress = HiveGeometry.FULLY_VISIBLE_PROGRESS, + modifier = Modifier.size(200.dp), + cornerRadius = 10.dp, + color = Color.Black, + maxAlpha = 1f, + ) + } + + /** Mid-wave: the staggered fade should leave the cells at visibly different opacities. */ + @Test + fun midWave() = capture("mid-wave") { + HiveProgress( + progress = 420f, + modifier = Modifier.size(200.dp), + colors = HiveProgressDefaults.Rainbow, + maxAlpha = 1f, + ) + } + + @Test + fun shrinkScalesWithOpacity() = capture("shrink") { + HiveProgress( + progress = 420f, + modifier = Modifier.size(200.dp), + shrink = true, + colors = HiveProgressDefaults.Rainbow, + maxAlpha = 1f, + ) + } + + private fun capture(name: String, content: @Composable () -> Unit) { + compose.setContent { + androidx.compose.foundation.layout.Box(Modifier.background(Color.White)) { content() } + } + compose.onRoot().captureRoboImage("src/test/screenshots/$name.png") + } +} diff --git a/overwatch-compose/src/test/screenshots/mid-wave.png b/overwatch-compose/src/test/screenshots/mid-wave.png new file mode 100644 index 0000000..4421df0 Binary files /dev/null and b/overwatch-compose/src/test/screenshots/mid-wave.png differ diff --git a/overwatch-compose/src/test/screenshots/rainbow-fully-visible.png b/overwatch-compose/src/test/screenshots/rainbow-fully-visible.png new file mode 100644 index 0000000..44ecc5e Binary files /dev/null and b/overwatch-compose/src/test/screenshots/rainbow-fully-visible.png differ diff --git a/overwatch-compose/src/test/screenshots/rounded-corners.png b/overwatch-compose/src/test/screenshots/rounded-corners.png new file mode 100644 index 0000000..75c795b Binary files /dev/null and b/overwatch-compose/src/test/screenshots/rounded-corners.png differ diff --git a/overwatch-compose/src/test/screenshots/shrink.png b/overwatch-compose/src/test/screenshots/shrink.png new file mode 100644 index 0000000..cbcb066 Binary files /dev/null and b/overwatch-compose/src/test/screenshots/shrink.png differ diff --git a/overwatch-compose/src/test/screenshots/wide-spacing.png b/overwatch-compose/src/test/screenshots/wide-spacing.png new file mode 100644 index 0000000..310a74b Binary files /dev/null and b/overwatch-compose/src/test/screenshots/wide-spacing.png differ diff --git a/overwatch-compose/src/test/screenshots/zero-spacing.png b/overwatch-compose/src/test/screenshots/zero-spacing.png new file mode 100644 index 0000000..4e57f18 Binary files /dev/null and b/overwatch-compose/src/test/screenshots/zero-spacing.png differ diff --git a/overwatch/build.gradle.kts b/overwatch/build.gradle.kts index 331dbec..aae71dc 100644 --- a/overwatch/build.gradle.kts +++ b/overwatch/build.gradle.kts @@ -50,3 +50,7 @@ publishing { } } } + +dependencies { + testImplementation(libs.junit) +} diff --git a/overwatch/src/main/java/com/comix/overwatch/HiveGeometry.java b/overwatch/src/main/java/com/comix/overwatch/HiveGeometry.java new file mode 100644 index 0000000..261852e --- /dev/null +++ b/overwatch/src/main/java/com/comix/overwatch/HiveGeometry.java @@ -0,0 +1,151 @@ +package com.comix.overwatch; + +/** + * Pure geometry and timing of the 2-3-2 hexagon comb, with no Android dependencies, so the View and + * the Compose implementations stay in lockstep instead of each carrying their own copy of the + * trigonometry. + * + *

Cells hold relative comb coordinates. A gap propagates through the horizontal pitch + * and the row step in a way that keeps all six neighbour gaps equal: for a comb of circumradius + * {@code r} with gap {@code g}, every neighbour centre sits {@code √3·r + g} away. + */ +public final class HiveGeometry { + + /** Number of hexagons in the comb. */ + public static final int HEX_COUNT = 7; + + public static final float SQRT_3 = (float) Math.sqrt(3); + + /** Progress value at which one full fade-in/fade-out cycle completes. */ + public static final int MAX_PROGRESS = 1450; + + /** + * Fraction of the content box used as the gap when no explicit spacing is supplied. Chosen to + * reproduce the gap this comb had before spacing became configurable. + */ + public static final float AUTO_SPACING_RATIO = 1f / 23f; + + /** Progress value at which every hexagon is fully faded in. */ + public static final float FULLY_VISIBLE_PROGRESS = 700f; + + private static final int FADE_OUT_START = 700; + private static final int FULLY_FADED = 1400; + + /** Column offset of each cell, counted in half a horizontal pitch. */ + private static final int[] CELL_HALF_COLUMN = {-1, 1, -2, 0, 2, -1, 1}; + + /** Row index of each cell, 0 (top) to 2 (bottom). */ + private static final int[] CELL_ROW = {0, 0, 1, 1, 1, 2, 2}; + + /** Position of each cell in the fade wave, preserving the original Overwatch ordering. */ + private static final int[] CELL_WAVE_ORDER = {1, 2, 6, 7, 3, 5, 4}; + + /** Per-cell tilt applied in image mode, in degrees. */ + private static final float[] CELL_IMAGE_TILT = {10, -10, -10, 5, -10, 20, 10}; + + private HiveGeometry() { + } + + /** Size-proportional gap used when the caller does not pin one. */ + public static float autoSpacing(float contentWidth, float contentHeight) { + return Math.min(contentWidth, contentHeight) * AUTO_SPACING_RATIO; + } + + /** + * Solves the hexagon circumradius from the space left over once the gaps are taken out. The comb + * occupies {@code 3√3·r + 2g} horizontally and {@code 5r + √3·g} vertically, so the radius is + * whichever of those two constraints binds first. + */ + public static float radiusFor(float contentWidth, float contentHeight, float gap) { + float fromWidth = (contentWidth - 2 * gap) / (3 * SQRT_3); + float fromHeight = (contentHeight - SQRT_3 * gap) / 5f; + return Math.max(0f, Math.min(fromWidth, fromHeight)); + } + + /** Centre-to-centre distance between two hexagons in the same row. */ + public static float pitch(float radius, float gap) { + return SQRT_3 * radius + gap; + } + + /** Vertical distance between two adjacent rows. */ + public static float rowStep(float radius, float gap) { + return 1.5f * radius + SQRT_3 * gap / 2f; + } + + /** Half the width of a pointy-top hexagon, i.e. the distance from centre to a vertical edge. */ + public static float halfWidth(float radius) { + return radius * SQRT_3 / 2f; + } + + public static float cellCenterX(int index, float originX, float radius, float gap) { + return originX + CELL_HALF_COLUMN[index] * pitch(radius, gap) / 2f; + } + + public static float cellCenterY(int index, float originY, float radius, float gap) { + return originY + (CELL_ROW[index] - 1) * rowStep(radius, gap); + } + + public static int waveOrder(int index) { + return CELL_WAVE_ORDER[index]; + } + + public static float imageTilt(int index) { + return CELL_IMAGE_TILT[index]; + } + + /** + * Opacity of a cell at a point in the wave, as a fraction in {@code [0, 1]}. Each cell fades in + * 100 progress units after the previous one, then the whole comb fades out in the same order. + * + * @param wave the cell's position in the wave, from {@link #waveOrder(int)} + */ + public static float alphaFractionAt(int wave, float progress) { + float fraction; + if (progress > wave * 100) { + fraction = 1f; + } else { + int min = (wave - 1) * 100; + fraction = Math.max(0f, progress - min) / 100f; + } + if (progress > FADE_OUT_START) { + float fadeProgress = progress - FADE_OUT_START; + if (fadeProgress > wave * 100) { + fraction = 0f; + } else { + int min = (wave - 1) * 100; + fraction = 1f - Math.max(0f, fadeProgress - min) / 100f; + } + } + if (progress > FULLY_FADED) { + fraction = 0f; + } + return fraction; + } + + /** Opacity of a cell scaled into the 0-255 range a {@code Paint} expects. */ + public static int alphaAt(int wave, float progress, int maxAlpha) { + return (int) (alphaFractionAt(wave, progress) * maxAlpha); + } + + /** + * Writes the six vertices of a pointy-top hexagon into {@code out}, as x,y pairs starting from + * the top vertex and running clockwise. + * + * @param out an array of at least 12 floats, overwritten in place + */ + public static void hexagonVertices(float cx, float cy, float r, float[] out) { + float edge = halfWidth(r); + out[0] = cx; + out[1] = cy - r; + out[2] = cx + edge; + out[3] = cy - r / 2f; + out[4] = cx + edge; + out[5] = cy + r / 2f; + out[6] = cx; + out[7] = cy + r; + out[8] = cx - edge; + out[9] = cy + r / 2f; + out[10] = cx - edge; + out[11] = cy - r / 2f; + } +} diff --git a/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java b/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java index dbd8bcc..5a3b9e8 100644 --- a/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java +++ b/overwatch/src/main/java/com/comix/overwatch/HiveProgressView.java @@ -16,48 +16,19 @@ public class HiveProgressView extends View { - /** Number of hexagons in the 2-3-2 comb. */ - private static final int HEX_COUNT = 7; - - private static final float SQRT_3 = (float) Math.sqrt(3); - private static final int[] RAINBOW_COLOR = { 0xFFFF0000, 0xFFFF7F00, 0xFFFFFF00, 0xFF00FF00, 0xFF0000FF, 0xFF4B0082, 0xFF9400D3 }; - /** - * Cells are stored as top-left, top-right, mid-left, mid-centre, mid-right, bottom-left, - * bottom-right. Column offsets are counted in half a horizontal pitch so the staggered - * rows stay integral. - */ - private static final int[] CELL_HALF_COLUMN = {-1, 1, -2, 0, 2, -1, 1}; - - private static final int[] CELL_ROW = {0, 0, 1, 1, 1, 2, 2}; - - /** Position of each cell in the fade wave, preserving the original Overwatch ordering. */ - private static final int[] CELL_WAVE_ORDER = {1, 2, 6, 7, 3, 5, 4}; - - /** Per-cell tilt applied in image mode, in degrees. */ - private static final float[] CELL_IMAGE_TILT = {10, -10, -10, 5, -10, 20, 10}; - - private static final int MAX_PROGRESS_VALUE = 1450; private static final int PROGRESS_TIME = 2000; private static final int MAX_ALPHA = 70; private static final int DEFAULT_SIZE_DP = 96; - /** - * Fraction of the content box used as the gap when {@link #hive_spacing} is not supplied. Chosen - * to reproduce the gap this view had before spacing became configurable. - */ - private static final float AUTO_SPACING_RATIO = 1f / 23f; - - /** Progress value at which every hexagon is fully faded in; used for layout previews. */ - private static final float PREVIEW_PROGRESS = 700f; - private final Paint paint = new Paint(); - private final Path[] hexPaths = new Path[HEX_COUNT]; - private final float[] cellX = new float[HEX_COUNT]; - private final float[] cellY = new float[HEX_COUNT]; + private final Path[] hexPaths = new Path[HiveGeometry.HEX_COUNT]; + private final float[] cellX = new float[HiveGeometry.HEX_COUNT]; + private final float[] cellY = new float[HiveGeometry.HEX_COUNT]; + private final float[] vertices = new float[12]; private float hexRadius; private float actualProgress = 0; @@ -85,14 +56,14 @@ public HiveProgressView(Context context, AttributeSet attrs) { public HiveProgressView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); - for (int i = 0; i < HEX_COUNT; i++) { + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { hexPaths[i] = new Path(); } initAttributes(attrs, defStyle); initPaint(); initImage(); if (isInEditMode()) { - actualProgress = PREVIEW_PROGRESS; + actualProgress = HiveGeometry.FULLY_VISIBLE_PROGRESS; } } @@ -242,7 +213,7 @@ public void setVisibility(int visibility) { private void startAnimation() { if (isInEditMode()) { - actualProgress = PREVIEW_PROGRESS; + actualProgress = HiveGeometry.FULLY_VISIBLE_PROGRESS; invalidate(); return; } @@ -259,7 +230,7 @@ private void stopAnimation() { private void resetAnimator() { stopAnimation(); - ValueAnimator animator = ValueAnimator.ofFloat(0, MAX_PROGRESS_VALUE); + ValueAnimator animator = ValueAnimator.ofFloat(0, HiveGeometry.MAX_PROGRESS); animator.setDuration(animationTime); animator.setInterpolator(new LinearInterpolator()); // Looping via the animator itself rather than restarting from onAnimationEnd: the old @@ -317,20 +288,14 @@ private void updateGeometry(int viewWidth, int viewHeight) { return; } - float gap = spacing >= 0 ? spacing - : Math.min(contentWidth, contentHeight) * AUTO_SPACING_RATIO; - float radiusFromWidth = (contentWidth - 2 * gap) / (3 * SQRT_3); - float radiusFromHeight = (contentHeight - SQRT_3 * gap) / 5f; - hexRadius = Math.max(0f, Math.min(radiusFromWidth, radiusFromHeight)); + float gap = spacing >= 0 ? spacing : HiveGeometry.autoSpacing(contentWidth, contentHeight); + hexRadius = HiveGeometry.radiusFor(contentWidth, contentHeight, gap); - float pitch = SQRT_3 * hexRadius + gap; - float rowStep = 1.5f * hexRadius + SQRT_3 * gap / 2f; float originX = getPaddingLeft() + contentWidth / 2f; float originY = getPaddingTop() + contentHeight / 2f; - - for (int i = 0; i < HEX_COUNT; i++) { - cellX[i] = originX + CELL_HALF_COLUMN[i] * pitch / 2f; - cellY[i] = originY + (CELL_ROW[i] - 1) * rowStep; + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + cellX[i] = HiveGeometry.cellCenterX(i, originX, hexRadius, gap); + cellY[i] = HiveGeometry.cellCenterY(i, originY, hexRadius, gap); } } @@ -347,9 +312,9 @@ protected void onDraw(Canvas canvas) { } private void drawHexagons(Canvas canvas) { - for (int i = 0; i < HEX_COUNT; i++) { - int wave = CELL_WAVE_ORDER[i]; - int alpha = getCellAlpha(wave, actualProgress); + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int wave = HiveGeometry.waveOrder(i); + int alpha = HiveGeometry.alphaAt(wave, actualProgress, maxAlpha); if (alpha <= 0) { continue; } @@ -362,14 +327,14 @@ private void drawHexagons(Canvas canvas) { } private void drawImages(Canvas canvas) { - float halfWidth = hexRadius * SQRT_3 / 2f; - for (int i = 0; i < HEX_COUNT; i++) { - int alpha = getCellAlpha(CELL_WAVE_ORDER[i], actualProgress); + float halfWidth = HiveGeometry.halfWidth(hexRadius); + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int alpha = HiveGeometry.alphaAt(HiveGeometry.waveOrder(i), actualProgress, maxAlpha); if (alpha <= 0) { continue; } canvas.save(); - canvas.rotate(CELL_IMAGE_TILT[i], cellX[i], cellY[i]); + canvas.rotate(HiveGeometry.imageTilt(i), cellX[i], cellY[i]); imageRes.setBounds(Math.round(cellX[i] - halfWidth), Math.round(cellY[i] - hexRadius), Math.round(cellX[i] + halfWidth), Math.round(cellY[i] + hexRadius)); imageRes.setAlpha(alpha); @@ -386,41 +351,14 @@ private int getHexagonColor(int wave) { } } - private int getCellAlpha(int num, float progress) { - float alpha; - if (progress > num * 100) { - alpha = maxAlpha; - } else { - int min = (num - 1) * 100; - alpha = (progress - min) > 0 ? progress - min : 0; - alpha = alpha * maxAlpha / 100; - } - if (progress > 700) { - float fadeProgress = progress - 700; - if (fadeProgress > num * 100) { - alpha = 0; - } else { - int min = (num - 1) * 100; - alpha = (fadeProgress - min) > 0 ? fadeProgress - min : 0; - alpha = maxAlpha - alpha * maxAlpha / 100; - } - } - if (progress > 1400) { - alpha = 0; - } - return (int) alpha; - } - /** Builds a pointy-top hexagon of circumradius {@code r} centred on ({@code cx}, {@code cy}). */ private void buildHexPath(Path path, float cx, float cy, float r) { - float edge = r * SQRT_3 / 2f; + HiveGeometry.hexagonVertices(cx, cy, r, vertices); path.reset(); - path.moveTo(cx, cy - r); - path.lineTo(cx + edge, cy - r / 2f); - path.lineTo(cx + edge, cy + r / 2f); - path.lineTo(cx, cy + r); - path.lineTo(cx - edge, cy + r / 2f); - path.lineTo(cx - edge, cy - r / 2f); + path.moveTo(vertices[0], vertices[1]); + for (int v = 2; v < vertices.length; v += 2) { + path.lineTo(vertices[v], vertices[v + 1]); + } path.close(); } } diff --git a/overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java b/overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java new file mode 100644 index 0000000..bc2b323 --- /dev/null +++ b/overwatch/src/test/java/com/comix/overwatch/HiveGeometryTest.java @@ -0,0 +1,133 @@ +package com.comix.overwatch; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class HiveGeometryTest { + + private static final float EPSILON = 0.01f; + + /** Index of the cell in the middle of the comb, which borders all six others. */ + private static final int CENTRE_CELL = 3; + + /** + * The property the spacing parameter is supposed to guarantee: one scalar gap widens the comb by + * the same amount in every direction. Without this, spacing would only look right along one axis. + */ + @Test + public void everyNeighbourOfTheCentreCellIsEquidistant() { + float box = 480f; + float gap = 12f; + float radius = HiveGeometry.radiusFor(box, box, gap); + float centreX = HiveGeometry.cellCenterX(CENTRE_CELL, box / 2f, radius, gap); + float centreY = HiveGeometry.cellCenterY(CENTRE_CELL, box / 2f, radius, gap); + float expected = HiveGeometry.SQRT_3 * radius + gap; + + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + if (i == CENTRE_CELL) { + continue; + } + float dx = HiveGeometry.cellCenterX(i, box / 2f, radius, gap) - centreX; + float dy = HiveGeometry.cellCenterY(i, box / 2f, radius, gap) - centreY; + assertEquals("cell " + i, expected, (float) Math.hypot(dx, dy), EPSILON); + } + } + + /** At zero gap the hexagons must tile exactly: touching, with no overlap. */ + @Test + public void zeroGapTilesExactly() { + float box = 300f; + float radius = HiveGeometry.radiusFor(box, box, 0f); + float centreX = HiveGeometry.cellCenterX(CENTRE_CELL, box / 2f, radius, 0f); + float centreY = HiveGeometry.cellCenterY(CENTRE_CELL, box / 2f, radius, 0f); + + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + if (i == CENTRE_CELL) { + continue; + } + float dx = HiveGeometry.cellCenterX(i, box / 2f, radius, 0f) - centreX; + float dy = HiveGeometry.cellCenterY(i, box / 2f, radius, 0f) - centreY; + assertEquals(HiveGeometry.SQRT_3 * radius, (float) Math.hypot(dx, dy), EPSILON); + } + } + + /** The radius is solved so that the comb always fits, whatever the gap. */ + @Test + public void combStaysInsideTheContentBox() { + for (float gap : new float[] {0f, 8f, 40f, 90f}) { + float width = 300f; + float height = 260f; + float radius = HiveGeometry.radiusFor(width, height, gap); + float halfWidth = HiveGeometry.halfWidth(radius); + + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + float x = HiveGeometry.cellCenterX(i, width / 2f, radius, gap); + float y = HiveGeometry.cellCenterY(i, height / 2f, radius, gap); + String cell = "gap " + gap + ", cell " + i; + assertTrue(cell, x - halfWidth >= -EPSILON); + assertTrue(cell, x + halfWidth <= width + EPSILON); + assertTrue(cell, y - radius >= -EPSILON); + assertTrue(cell, y + radius <= height + EPSILON); + } + } + } + + @Test + public void radiusCollapsesToZeroRatherThanGoingNegative() { + assertEquals(0f, HiveGeometry.radiusFor(100f, 100f, 500f), EPSILON); + assertEquals(0f, HiveGeometry.radiusFor(0f, 0f, 0f), EPSILON); + } + + @Test + public void cellsFadeInOneAfterAnother() { + assertEquals(0f, HiveGeometry.alphaFractionAt(1, 0f), EPSILON); + assertEquals(1f, HiveGeometry.alphaFractionAt(1, 100f), EPSILON); + // The last cell in the wave is still dark when the first one is already lit. + assertEquals(0f, HiveGeometry.alphaFractionAt(7, 100f), EPSILON); + assertEquals(1f, HiveGeometry.alphaFractionAt(7, HiveGeometry.FULLY_VISIBLE_PROGRESS), EPSILON); + } + + @Test + public void everyCellIsDarkAtBothEndsOfTheCycle() { + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int wave = HiveGeometry.waveOrder(i); + assertEquals("cell " + i, 0f, HiveGeometry.alphaFractionAt(wave, 0f), EPSILON); + assertEquals("cell " + i, 0f, + HiveGeometry.alphaFractionAt(wave, HiveGeometry.MAX_PROGRESS), EPSILON); + } + } + + @Test + public void alphaStaysWithinRangeAcrossTheWholeCycle() { + for (float progress = 0f; progress <= HiveGeometry.MAX_PROGRESS; progress += 7f) { + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + float fraction = HiveGeometry.alphaFractionAt(HiveGeometry.waveOrder(i), progress); + assertTrue("progress " + progress, fraction >= 0f && fraction <= 1f); + } + } + } + + /** Every cell appears exactly once in the wave, so no hexagon is skipped or doubled up. */ + @Test + public void waveOrderIsAPermutation() { + boolean[] seen = new boolean[HiveGeometry.HEX_COUNT + 1]; + for (int i = 0; i < HiveGeometry.HEX_COUNT; i++) { + int wave = HiveGeometry.waveOrder(i); + assertTrue("wave " + wave + " out of range", wave >= 1 && wave <= HiveGeometry.HEX_COUNT); + assertTrue("wave " + wave + " repeated", !seen[wave]); + seen[wave] = true; + } + } + + @Test + public void hexagonVerticesLieOnTheCircumcircle() { + float[] vertices = new float[12]; + HiveGeometry.hexagonVertices(50f, 60f, 20f, vertices); + for (int v = 0; v < vertices.length; v += 2) { + double distance = Math.hypot(vertices[v] - 50f, vertices[v + 1] - 60f); + assertEquals(20f, (float) distance, EPSILON); + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2183dfe..b9d3bf1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,4 +22,4 @@ dependencyResolutionManagement { rootProject.name = "OverwatchProgress" -include(":app", ":overwatch") +include(":app", ":overwatch", ":overwatch-compose")