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)) + } +}