diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 3943090..1ff89c5 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -13,7 +13,6 @@ body:
label: Describe the bug..
description: Also tell us, what did you expect to happen?
placeholder: Tell us what you see!
- value: "A bug happened!"
validations:
required: true
- type: textarea
@@ -44,10 +43,18 @@ body:
- label: visionOS
validations:
required: true
+ - type: input
+ id: version
+ attributes:
+ label: Which ProgressUI version are you using?
+ description: The release tag, or the commit SHA if building from a branch.
+ placeholder: "1.2.0"
+ validations:
+ required: true
- type: input
id: xcode
attributes:
label: What Xcode version do you use?
- placeholder: "16.0"
+ placeholder: "26.0"
validations:
required: true
diff --git a/.github/PULL_REQUEST_TEMPLATE/main.md b/.github/PULL_REQUEST_TEMPLATE.md
similarity index 95%
rename from .github/PULL_REQUEST_TEMPLATE/main.md
rename to .github/PULL_REQUEST_TEMPLATE.md
index 4a69fd9..a3acb71 100644
--- a/.github/PULL_REQUEST_TEMPLATE/main.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -30,11 +30,12 @@ _Select all that apply:_
- [ ] Other: ___
**Test Configuration**
-- OS: `iOS 18`
+- OS: `iOS 26`
- Device: `iPhone 16`
-- Environment: `Simulator, Xcode 15`
+- Environment: `Simulator, Xcode 26`
- Additional notes: `e.g. Dark mode, background refresh, etc.`
-- ---
+
+---
## 📎 Checklist
Please confirm the following before requesting review:
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 59cd44d..7a8d67b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,19 +2,64 @@ name: Build & Test
on:
pull_request:
+ branches: [main, develop]
+ # `develop` is only ever reached via PR (already tested above), so build on
+ # push for `main` only — release merges/tags. Avoids double runs per merge.
+ push:
branches: [main]
+# Least-privilege GITHUB_TOKEN — these jobs only check out and build.
+permissions:
+ contents: read
+
+# Cancel superseded runs for the same ref (e.g. rapid pushes to an open PR).
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
jobs:
- build:
+ test:
+ name: SwiftPM build & test
runs-on: macos-latest
-
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- - name: Show Xcode version
+ - name: Show toolchain versions
run: |
xcodebuild -version
swift --version
- - name: Build Swift Package
- run: swift build --build-tests
+ - name: Build
+ run: swift build
+
+ - name: Test
+ run: swift test
+
+ platforms:
+ name: Build (${{ matrix.name }})
+ runs-on: macos-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: iOS
+ destination: 'generic/platform=iOS Simulator'
+ - name: macOS
+ destination: 'platform=macOS'
+ - name: macCatalyst
+ destination: 'platform=macOS,variant=Mac Catalyst'
+ - name: watchOS
+ destination: 'generic/platform=watchOS Simulator'
+ - name: tvOS
+ destination: 'generic/platform=tvOS Simulator'
+ - name: visionOS
+ destination: 'generic/platform=visionOS Simulator'
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build ProgressUI for ${{ matrix.name }}
+ run: |
+ set -o pipefail
+ xcodebuild build \
+ -scheme ProgressUI \
+ -destination '${{ matrix.destination }}'
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..c8bbedc
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,49 @@
+name: Documentation
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+# Allow the workflow to publish to GitHub Pages.
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Only one Pages deployment at a time; cancel superseded runs.
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: Build DocC
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Generate documentation
+ run: |
+ swift package --allow-writing-to-directory ./docs \
+ generate-documentation \
+ --target ProgressUI \
+ --disable-indexing \
+ --transform-for-static-hosting \
+ --hosting-base-path ProgressUI \
+ --output-path ./docs
+
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: ./docs
+
+ deploy:
+ name: Deploy to Pages
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 0023a53..1823cea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,13 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
+# Resolved versions — library does not pin a lockfile for consumers.
+Package.resolved
+# Generated DocC site (built and deployed by the Documentation workflow).
+/docs
+# Claude Code local state.
+.claude/scheduled_tasks.lock
+# Personal/local files (e.g. CLAUDE.local.md, *.local.json) — not shared.
+*.local.*
+# Tech-debt MCP report output (generated, not part of the project).
+/td
diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ProgressUI.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ProgressUI.xcscheme
new file mode 100644
index 0000000..8288dd1
--- /dev/null
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/ProgressUI.xcscheme
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..289054c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,40 @@
+# Changelog
+
+All notable changes to this project are documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+## [1.1.0] - 2026-05-31
+
+### Added
+- Public memberwise initializer for `Options`, allowing consumers to construct
+ a configuration directly (e.g. `Options(progressColor: .blue)`) instead of
+ only through the `set…` modifiers.
+- Unit test target (`ProgressUITests`) covering `Options` defaults, the new
+ initializer, `Progressable.calculate(from:)` bucketing, and `GrowDirection`
+ alignment mapping.
+- DocC catalog with a landing page and curated topics.
+- DocC documentation published to GitHub Pages via a Documentation workflow
+ (using `swift-docc-plugin`).
+- Contribution guide, issue/PR templates, and a `CHANGELOG`.
+
+### Changed
+- CI now runs the test suite and builds across every supported platform
+ (iOS, macOS, macCatalyst, watchOS, tvOS, visionOS), and avoids redundant
+ runs (push builds `main` only; superseded runs are cancelled).
+
+### Fixed
+- Corrected the README "Dynamic Colors" and "Customization Options" examples,
+ which previously did not compile.
+- Resolved DocC symbol-link warnings in the `Options.size`/`setSize(_:)` size
+ tables and the `GrowDirection.end` default links; DocC now builds cleanly.
+
+### Security
+- Set a least-privilege `GITHUB_TOKEN` (`contents: read`) on the Build & Test
+ workflow, resolving the CodeQL `actions/missing-workflow-permissions` alerts.
+
+[Unreleased]: https://github.com/PierreJanineh-com/ProgressUI/compare/1.1.0...HEAD
+[1.1.0]: https://github.com/PierreJanineh-com/ProgressUI/compare/1.0.4...1.1.0
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..29c899c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,48 @@
+# ProgressUI
+
+SwiftUI library providing a customizable circular/linear progress indicator. Distributed as a Swift Package (SPM).
+
+## Build & verify
+
+```bash
+swift build # build the library
+swift test # run the ProgressUITests suite
+swift build -c release # release build
+xcodebuild -scheme ProgressUI -destination 'generic/platform=iOS Simulator' build
+```
+
+Unit tests live in `Tests/ProgressUITests/` (XCTest). To exercise UI changes interactively, open `Example/Example.xcodeproj` (iOS/macOS/watchOS targets) and run the `Example` scheme.
+
+> If a bare `swift build`/`swift test` fails with an SDK/compiler mismatch (e.g. "failed to build module 'Darwin'… SDK is built with a different Swift version"), the `swift` on `PATH` is an older standalone toolchain (e.g. swiftly) that doesn't match the Xcode SDK. Run via Xcode's toolchain instead: `xcrun swift build` / `xcrun swift test`.
+
+## Layout
+
+- `Package.swift` — single library target `ProgressUI`. Supports iOS 14+, macOS 11+, macCatalyst 14+, watchOS 7+, tvOS 15+, visionOS 1+. Any new API must be available on all of these.
+- `Sources/ProgressUI/`
+ - `Components/ProgressUI/` — public entry point.
+ - `ProgressUI.swift` — the `ProgressUI` SwiftUI `View`.
+ - `ProgressUI+Modifiers.swift` — fluent `.option(...)` style modifiers.
+ - `ProgressUI+ViewModel.swift` — internal observable state.
+ - `Components/BaseProgress.swift` — shared shape/animation scaffolding.
+ - `Components/CircularProgress.swift`, `LinearProgress.swift` — the two `Shape` implementations selected via `Options.shape`.
+ - `Options.swift` — the single configuration struct passed into `ProgressUI`.
+ - `Progressable.swift` — protocol consumers implement to drive dynamic coloring (`color`, optional `innerColor`, `calculate(from:)`).
+ - `Shape.swift`, `GrowDirection.swift`, `ProgressSize.swift` — small enums used by `Options`.
+- `Example/` — multi-platform sample app demonstrating usage.
+
+## Conventions
+
+- Public API surface is `ProgressUI`, `Options`, `Progressable`, `Shape`, `GrowDirection`, `ProgressSize`. Treat changes to these as semver-relevant.
+- Configuration flows through `Options` — prefer adding a field there over adding new initializers on `ProgressUI`.
+- State-driven coloring is opt-in via the generic `statusType:` parameter taking a `Progressable.Type`.
+- Keep view-extension files (`ProgressUI+*.swift`) split by concern rather than collapsing into the main view file.
+
+## Branching & releases (Git Flow)
+
+- `main` — stable, release-only. Every commit on `main` corresponds to a tagged release; never commit feature work directly here.
+- `develop` — the integration branch. All day-to-day work merges here first.
+- `feature/*` — branch off `develop`, merge back into `develop` via PR. CI (build + test across all platforms) must be green before merge.
+- `release/*` — cut from `develop` when preparing a version: bump the version, finalize `CHANGELOG.md`, then merge into both `main` (tagged) and `develop`.
+- `hotfix/*` — branch off `main` for urgent fixes, merge into both `main` (tagged) and `develop`.
+- Tag releases with semantic versions (`MAJOR.MINOR.PATCH`); public-API changes to the types above drive the version bump. Update `CHANGELOG.md` under `[Unreleased]` as you go.
+- Open PRs against `develop` (not `main`).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..bafa9c7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,46 @@
+# Contributing to ProgressUI
+
+Thanks for your interest in improving ProgressUI! This document covers the
+essentials for contributing.
+
+## Getting started
+
+1. Fork the repository and create a feature branch off `develop`:
+ ```bash
+ git checkout -b feature/my-feature develop
+ ```
+2. Make your change.
+3. Build and test:
+ ```bash
+ swift build
+ swift test
+ ```
+4. For UI changes, run the sample app in `Example/Example.xcodeproj` (iOS,
+ macOS, and watchOS targets are available) to verify behavior interactively.
+
+## Project layout
+
+- `Sources/ProgressUI/` — the library. Public API surface is `ProgressUI`,
+ `Options`, `Progressable`, `Shape`, `GrowDirection`, and `ProgressSize`.
+- `Tests/ProgressUITests/` — XCTest unit tests.
+- `Example/` — multi-platform sample app.
+
+## Guidelines
+
+- **Platform availability.** The package supports iOS 14+, macOS 11+,
+ macCatalyst 14+, watchOS 7+, tvOS 15+, and visionOS 1+. Any new API must be
+ available on all of these.
+- **Configuration flows through `Options`.** Prefer adding a field to `Options`
+ (and a matching `set…` modifier) over adding new initializers to `ProgressUI`.
+- **Public API is semver-relevant.** Treat changes to the public types above as
+ breaking unless they are purely additive.
+- **Document public API** with `///` or `/** */` doc comments, and update the
+ DocC landing page (`Sources/ProgressUI/ProgressUI.docc/ProgressUI.md`) when
+ adding new public symbols.
+- **Add tests** for new logic where practical.
+- **Update `CHANGELOG.md`** under `[Unreleased]`.
+
+## Pull requests
+
+Open pull requests against `develop`. CI builds and tests across all supported
+platforms; please make sure it is green before requesting review.
diff --git a/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme
new file mode 100644
index 0000000..731145b
--- /dev/null
+++ b/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Example/Example/ContentView.swift b/Example/Example/ContentView.swift
index ccdb96a..94cd77b 100644
--- a/Example/Example/ContentView.swift
+++ b/Example/Example/ContentView.swift
@@ -8,13 +8,30 @@
import SwiftUI
import ProgressUI
+extension ContentView {
+ final class ViewModel: ObservableObject {
+ @Published var value: CGFloat = 0
+
+ init() {
+ loop()
+ }
+
+ private func loop() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
+ guard let self else { return }
+ self.value = .random(in: 0...1)
+ self.loop()
+ }
+ }
+ }
+}
+
struct ContentView: View {
+ @StateObject private var vm: ViewModel = .init()
+
@State private var liveProgress: CGFloat = 0
let liveTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
- @State private var loadingProgress: CGFloat = 0
- let loadingTimer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
-
@State private var spinnerProgress: CGFloat = 0.01
@State private var isForward: Bool = true
let spinnerTimer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
@@ -105,25 +122,28 @@ struct ContentView: View {
}
.aspectRatio(1, contentMode: .fit)
- ProgressUI(progress: $loadingProgress, statusType: Status.self)
- .setTrackWidth(25)
- .setInnerProgressWidth(10)
- .setTrackColor(.black.opacity(0.1))
- .setIsSpinner()
- .setGrow(from: .center)
- .setAnimationMaxValue(1)
-#if os(watchOS)
- .setTrackWidth(8)
- .setInnerProgressWidth(4)
-#endif
- .onReceive(loadingTimer) { _ in
- // Create a spinner that grows between 0 and 100 continuously
- if loadingProgress >= 1 {
- loadingProgress = 0
- return
- }
- loadingProgress += 0.1
- }
+ ProgressUI(progress: vm.value)
+ .setSize(.small)
+ .setShape(.linear(.zero))
+ .setIsRounded(true)
+ .setIsSpinner(false)
+ .setInnerProgressWidth(8.0)
+ .setInnerProgressColor(Color.red)
+ .setTrackWidth(8.0)
+ .setGrow(from: .start)
+ .setTrackColor(.yellow)
+ .frame(maxHeight: 8.0)
+
+ GeometryReader { geometry in
+ ProgressUI(progress: 0.5)
+ .setShape(.linear(.zero))
+ .setIsSpinner(false)
+ .setInnerProgressWidth(0)
+ .setProgressColor(.red)
+ .setTrackColor(.yellow)
+ .setAnimationMaxValue(strokeRatio(forWidth: geometry.size.width))
+ .setTrackWidth(8)
+ }.frame(height: 8.0)
ProgressUI(progress: $spinnerProgress)
.setTrackWidth(15)
@@ -160,6 +180,12 @@ struct ContentView: View {
.frame(maxWidth: .infinity, alignment: .center)
.background(.white)
}
+
+ /// The 8pt stroke expressed as a fraction of the available width.
+ /// Guards against the 0-width GeometryReader reports on first layout.
+ private func strokeRatio(forWidth width: CGFloat) -> CGFloat {
+ width > 0 ? 8 / width : 0
+ }
}
#Preview {
diff --git a/Package.swift b/Package.swift
index cfba475..3f1fc47 100644
--- a/Package.swift
+++ b/Package.swift
@@ -19,11 +19,17 @@ let package = Package(
name: "ProgressUI",
targets: ["ProgressUI"]),
],
+ dependencies: [
+ // Build-tool plugin only; does not affect the library's runtime dependencies.
+ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
+ ],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.target(
name: "ProgressUI"),
-
+ .testTarget(
+ name: "ProgressUITests",
+ dependencies: ["ProgressUI"]),
]
)
diff --git a/README.md b/README.md
index 077a931..8cab3aa 100644
--- a/README.md
+++ b/README.md
@@ -53,11 +53,9 @@ enum StorageStatus: CaseIterable, Progressable {
case warning
case critical
case full
-
- var color: Color { innerColor.opacity(0.4) }
-
- // Optional: Add inner color for layered effect
- var innerColor: Color? {
+
+ // Main (outer) progress color
+ var color: Color {
switch self {
case .safe: return .green
case .warning: return .yellow
@@ -65,17 +63,16 @@ enum StorageStatus: CaseIterable, Progressable {
case .full: return .red
}
}
-
- static func calculate(from progress: CGFloat) -> Status {
- let level: CGFloat = CGFloat(1) / CGFloat(Status.allCases.count)
-
- return switch progress {
- case 0...level: Excellent
- case level...(level * 2): Normal
- case (level * 2)...(level * 3): SemiNormal
- case (level * 3)...(level * 4): Bad
- case (level * 4)...(level * 5): Critical
- default: Danger
+
+ // Optional: inner color for a layered effect
+ var innerColor: Color? { color.opacity(0.4) }
+
+ static func calculate(from progress: CGFloat) -> StorageStatus {
+ switch progress {
+ case ..<0.25: return .safe
+ case ..<0.5: return .warning
+ case ..<0.75: return .critical
+ default: return .full
}
}
}
@@ -125,8 +122,8 @@ let options = Options(
isClockwise: true, // Rotation direction
growFrom: .end, // Growth direction
isSpinner: false, // Enable spinner mode
- spinnerCycleDuration: 2 // Duration of spinner rotation
- shape: .circular // Duration of spinner rotation
+ spinnerCycleDuration: 2, // Duration of a full spinner cycle (seconds)
+ shape: .circular // Progress shape (.circular or .linear)
)
```
diff --git a/Sources/ProgressUI/Components/ProgressUI/ProgressUI+Modifiers.swift b/Sources/ProgressUI/Components/ProgressUI/ProgressUI+Modifiers.swift
index 7d1775f..d489a0a 100644
--- a/Sources/ProgressUI/Components/ProgressUI/ProgressUI+Modifiers.swift
+++ b/Sources/ProgressUI/Components/ProgressUI/ProgressUI+Modifiers.swift
@@ -14,9 +14,9 @@ extension ProgressUI {
///
/// Default: ``ProgressSize/large``.
///
- /// ``ProgressSize/large`` > ``trackWidth`` `= 45`, ``progressLineWidth`` `= 10`, ``progressInnerLineWidth`` `= 5`, ``radius`` `= 60`
+ /// ``ProgressSize/large`` > `trackWidth = 45`, `progressLineWidth = 10`, `progressInnerLineWidth = 5`, `radius = 60`
///
- /// ``ProgressSize/small`` > ``trackWidth`` = `15`, ``progressLineWidth`` `= 5`, ``progressInnerLineWidth`` `= 2.5`, ``radius`` `= 30`
+ /// ``ProgressSize/small`` > `trackWidth = 15`, `progressLineWidth = 5`, `progressInnerLineWidth = 2.5`, `radius = 30`
public func setSize(_ size: ProgressSize) -> Self {
var copy = self
copy.options.size = size
@@ -129,10 +129,10 @@ extension ProgressUI {
}
/// Sets the direction the progress arc growth.
- /// - Parameter from: Growing animation direction (default: .end).
+ /// - Parameter direction: Growing animation direction (default: .end).
/// - Returns: A modified ProgressUI instance.
///
- /// Default: ``ProgressUI/GrowDirection/end.
+ /// Default: ``GrowDirection/end``.
///
/// > Setting this value to ``ProgressUI/GrowDirection/center`` with rotation
public func setGrow(from direction: GrowDirection = .end) -> Self {
diff --git a/Sources/ProgressUI/Options.swift b/Sources/ProgressUI/Options.swift
index b1e79e2..5841a5b 100644
--- a/Sources/ProgressUI/Options.swift
+++ b/Sources/ProgressUI/Options.swift
@@ -9,14 +9,68 @@ import SwiftUI
/// Options for customizing the appearance and behavior of ProgressUI.
public struct Options {
+ /**
+ Creates a configuration for ``ProgressUI``.
+
+ Every parameter has a default, so you only need to specify the values you
+ want to override, e.g. `Options(isRounded: false, progressColor: .blue)`.
+
+ - Parameters:
+ - size: The size preset. Default: ``ProgressSize/large``.
+ - trackColor: The color of the empty track. Default: `Color.black`.
+ - trackWidth: An explicit track width. Default: `nil` (derived from `size`).
+ - progressColor: The progress color. Default: `Color.green`.
+ - animationMaxValue: Progress threshold for the start-of-growth width animation. `nil` disables it. Default: `0.03`.
+ - animation: The animation applied to progress changes. Default: `.easeInOut(duration: 0.5)`.
+ - innerProgressWidth: An explicit inner progress width. Default: `nil` (derived from `size`).
+ - innerProgressColor: The inner progress color. Default: `Color.black.opacity(0.2)`.
+ - isRounded: Whether line caps are rounded. Default: `true`.
+ - isClockwise: Whether the spinner rotates clockwise. Default: `true`.
+ - growFrom: The direction the progress grows from. Default: ``GrowDirection/end``.
+ - isSpinner: Whether to run in indeterminate spinner mode. Default: `false`.
+ - spinnerCycleDuration: Seconds for one full spinner cycle. Default: `1`.
+ - shape: The progress shape. Default: ``Shape/circular``.
+ */
+ public init(
+ size: ProgressSize = .large,
+ trackColor: Color = .black,
+ trackWidth: CGFloat? = nil,
+ progressColor: Color = .green,
+ animationMaxValue: CGFloat? = 0.03,
+ animation: Animation = .easeInOut(duration: 0.5),
+ innerProgressWidth: CGFloat? = nil,
+ innerProgressColor: Color? = .black.opacity(0.2),
+ isRounded: Bool = true,
+ isClockwise: Bool = true,
+ growFrom: GrowDirection = .end,
+ isSpinner: Bool = false,
+ spinnerCycleDuration: TimeInterval = 1,
+ shape: Shape = .circular
+ ) {
+ self.size = size
+ self.trackColor = trackColor
+ self.trackWidth = trackWidth
+ self.progressColor = progressColor
+ self.animationMaxValue = animationMaxValue
+ self.animation = animation
+ self.innerProgressWidth = innerProgressWidth
+ self.innerProgressColor = innerProgressColor
+ self.isRounded = isRounded
+ self.isClockwise = isClockwise
+ self.growFrom = growFrom
+ self.isSpinner = isSpinner
+ self.spinnerCycleDuration = spinnerCycleDuration
+ self.shape = shape
+ }
+
/**
The size of the circle.
Default: ``ProgressSize/large``.
- ``ProgressSize/large`` > ``trackWidth`` `= 45`, ``progressLineWidth`` `= 10`, ``progressInnerLineWidth`` `= 5`, ``radius`` `= 60`
-
- ``ProgressSize/small`` > ``trackWidth`` = `15`, ``progressLineWidth`` `= 5`, ``progressInnerLineWidth`` `= 2.5`, ``radius`` `= 30`
+ ``ProgressSize/large`` > `trackWidth = 45`, `progressLineWidth = 10`, `progressInnerLineWidth = 5`, `radius = 60`
+
+ ``ProgressSize/small`` > `trackWidth = 15`, `progressLineWidth = 5`, `progressInnerLineWidth = 2.5`, `radius = 30`
> You can set this with the modifier ``ProgressUI/ProgressUI/setSize(_:)``.
*/
@@ -106,7 +160,7 @@ public struct Options {
/**
Determines where the growing animation should go.
- Default: ``ProgressUI/GrowDirection/end.
+ Default: ``GrowDirection/end``.
> You can set this with the modifier ``ProgressUI/ProgressUI/setGrow(from:)``.
*/
diff --git a/Sources/ProgressUI/ProgressUI.docc/ProgressUI.md b/Sources/ProgressUI/ProgressUI.docc/ProgressUI.md
new file mode 100644
index 0000000..bd89bde
--- /dev/null
+++ b/Sources/ProgressUI/ProgressUI.docc/ProgressUI.md
@@ -0,0 +1,52 @@
+# ``ProgressUI``
+
+A highly customizable circular and linear progress indicator for SwiftUI.
+
+## Overview
+
+`ProgressUI` renders determinate or indeterminate progress with dynamic,
+state-driven coloring, multiple size presets, adjustable stroke widths and line
+caps, and smooth animations. It runs on iOS, macOS, macCatalyst, watchOS, tvOS,
+and visionOS.
+
+```swift
+import SwiftUI
+import ProgressUI
+
+struct ContentView: View {
+ var body: some View {
+ ProgressUI(progress: 0.5)
+ }
+}
+```
+
+Customize appearance either by passing an ``Options`` value or by chaining the
+`set…` modifiers:
+
+```swift
+ProgressUI(progress: 0.5, options: Options(progressColor: .blue, isRounded: false))
+
+ProgressUI(progress: 0.5)
+ .setProgressColor(.blue)
+ .setIsRounded(false)
+```
+
+Drive coloring from progress state by conforming a `CaseIterable` type to
+``Progressable`` and passing it as `statusType:`.
+
+## Topics
+
+### Creating a progress indicator
+
+- ``ProgressUI/ProgressUI``
+
+### Configuration
+
+- ``Options``
+- ``ProgressSize``
+- ``Shape``
+- ``GrowDirection``
+
+### Dynamic coloring
+
+- ``Progressable``
diff --git a/Tests/ProgressUITests/GrowDirectionTests.swift b/Tests/ProgressUITests/GrowDirectionTests.swift
new file mode 100644
index 0000000..18834e0
--- /dev/null
+++ b/Tests/ProgressUITests/GrowDirectionTests.swift
@@ -0,0 +1,21 @@
+//
+// GrowDirectionTests.swift
+// ProgressUI
+//
+// Created by Pierre Janineh on 30/05/2026.
+//
+
+import XCTest
+import SwiftUI
+@testable import ProgressUI
+
+final class GrowDirectionTests: XCTestCase {
+
+ /// `alignment` is the internal bridge GrowDirection uses to position the growing arc;
+ /// each case must map to the matching SwiftUI alignment.
+ func testAlignmentMapping() {
+ XCTAssertEqual(GrowDirection.start.alignment, .leading)
+ XCTAssertEqual(GrowDirection.center.alignment, .center)
+ XCTAssertEqual(GrowDirection.end.alignment, .trailing)
+ }
+}
diff --git a/Tests/ProgressUITests/OptionsTests.swift b/Tests/ProgressUITests/OptionsTests.swift
new file mode 100644
index 0000000..538edc6
--- /dev/null
+++ b/Tests/ProgressUITests/OptionsTests.swift
@@ -0,0 +1,78 @@
+//
+// OptionsTests.swift
+// ProgressUI
+//
+// Created by Pierre Janineh on 30/05/2026.
+//
+
+import XCTest
+import SwiftUI
+@testable import ProgressUI
+
+final class OptionsTests: XCTestCase {
+
+ func testDefaultsMatchDocumentedValues() {
+ let options = Options()
+
+ XCTAssertEqual(options.trackColor, .black)
+ XCTAssertEqual(options.progressColor, .green)
+ XCTAssertEqual(options.animationMaxValue, 0.03)
+ XCTAssertNil(options.trackWidth)
+ XCTAssertNil(options.innerProgressWidth)
+ XCTAssertEqual(options.innerProgressColor, .black.opacity(0.2))
+ XCTAssertTrue(options.isRounded)
+ XCTAssertTrue(options.isClockwise)
+ XCTAssertFalse(options.isSpinner)
+ XCTAssertEqual(options.spinnerCycleDuration, 1)
+
+ // GrowDirection / ProgressSize / Shape aren't Equatable — pattern-match instead.
+ XCTAssertEqual(options.growFrom.alignment, .trailing) // .end
+ guard case .large = options.size else {
+ return XCTFail("default size should be .large")
+ }
+ guard case .circular = options.shape else {
+ return XCTFail("default shape should be .circular")
+ }
+ }
+
+ /// The public memberwise init must let consumers override only the fields they care about.
+ func testPublicInitOverridesSelectedFields() {
+ let options = Options(
+ trackColor: .gray,
+ progressColor: .blue,
+ isRounded: false,
+ growFrom: .center,
+ isSpinner: true,
+ spinnerCycleDuration: 2
+ )
+
+ XCTAssertEqual(options.trackColor, .gray)
+ XCTAssertEqual(options.progressColor, .blue)
+ XCTAssertFalse(options.isRounded)
+ XCTAssertEqual(options.growFrom.alignment, .center)
+ XCTAssertTrue(options.isSpinner)
+ XCTAssertEqual(options.spinnerCycleDuration, 2)
+
+ // Untouched fields keep their defaults.
+ XCTAssertEqual(options.animationMaxValue, 0.03)
+ XCTAssertTrue(options.isClockwise)
+ }
+
+ func testLinearShapeCarriesPadding() {
+ let options = Options(shape: .linear(20))
+ guard case .linear(let padding) = options.shape else {
+ return XCTFail("expected linear shape")
+ }
+ XCTAssertEqual(padding, 20)
+ }
+
+ func testLinearShapeDefaultPadding() {
+ // `Shape` (the package enum) collides with `SwiftUI.Shape`, and the
+ // `ProgressUI` type name shadows the module — so reach the value via Options.
+ let options = Options(shape: .linear())
+ guard case .linear(let padding) = options.shape else {
+ return XCTFail("expected linear shape")
+ }
+ XCTAssertEqual(padding, 15)
+ }
+}
diff --git a/Tests/ProgressUITests/ProgressableTests.swift b/Tests/ProgressUITests/ProgressableTests.swift
new file mode 100644
index 0000000..89cf053
--- /dev/null
+++ b/Tests/ProgressUITests/ProgressableTests.swift
@@ -0,0 +1,74 @@
+//
+// ProgressableTests.swift
+// ProgressUI
+//
+// Created by Pierre Janineh on 30/05/2026.
+//
+
+import XCTest
+import SwiftUI
+@testable import ProgressUI
+
+/// A four-bucket sample status, mirroring the README's StorageStatus example.
+private enum SampleStatus: CaseIterable, Progressable, Equatable {
+ case safe, warning, critical, full
+
+ var color: Color {
+ switch self {
+ case .safe: return .green
+ case .warning: return .yellow
+ case .critical: return .orange
+ case .full: return .red
+ }
+ }
+
+ var innerColor: Color? { color.opacity(0.4) }
+
+ static func calculate(from progress: CGFloat) -> SampleStatus {
+ switch progress {
+ case ..<0.25: return .safe
+ case ..<0.5: return .warning
+ case ..<0.75: return .critical
+ default: return .full
+ }
+ }
+}
+
+/// A status that omits `innerColor` to exercise the protocol's default implementation.
+private enum MinimalStatus: CaseIterable, Progressable, Equatable {
+ case low, high
+
+ var color: Color { self == .low ? .green : .red }
+
+ static func calculate(from progress: CGFloat) -> MinimalStatus {
+ progress < 0.5 ? .low : .high
+ }
+}
+
+final class ProgressableTests: XCTestCase {
+
+ func testCalculateBuckets() {
+ XCTAssertEqual(SampleStatus.calculate(from: 0), .safe)
+ XCTAssertEqual(SampleStatus.calculate(from: 0.24), .safe)
+ XCTAssertEqual(SampleStatus.calculate(from: 0.25), .warning)
+ XCTAssertEqual(SampleStatus.calculate(from: 0.49), .warning)
+ XCTAssertEqual(SampleStatus.calculate(from: 0.5), .critical)
+ XCTAssertEqual(SampleStatus.calculate(from: 0.74), .critical)
+ XCTAssertEqual(SampleStatus.calculate(from: 0.75), .full)
+ XCTAssertEqual(SampleStatus.calculate(from: 1), .full)
+ }
+
+ /// Out-of-range values fall through to the final bucket rather than trapping.
+ func testCalculateClampsBeyondRange() {
+ XCTAssertEqual(SampleStatus.calculate(from: 5), .full)
+ }
+
+ func testInnerColorDefaultsToNil() {
+ XCTAssertNil(MinimalStatus.low.innerColor)
+ XCTAssertNil(MinimalStatus.high.innerColor)
+ }
+
+ func testInnerColorWhenProvided() {
+ XCTAssertEqual(SampleStatus.safe.innerColor, Color.green.opacity(0.4))
+ }
+}