diff --git a/Package.swift b/Package.swift index 1bdba7c..e8f0a17 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.4 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/README.md b/README.md index d71af3e..f9916f1 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ For users who previously used AboutKit for showing the features list, please use 2. [Integration](#integration) 3. [Usage](#usage) - [AKConfiguration](#akconfiguration) + - [AKSection](#aksection) + - [AKEntry](#akentry) - [AKMyApp](#akmyapp) - [AKDeveloper](#akdeveloper) - [AKProfile](#akprofile) @@ -78,6 +80,82 @@ let configuration = AKConfiguration( ) ``` +The default section order matches the standard AboutKit layout. Supply a custom `sections` array to reorder built-in sections or insert your own sections. + +### AKSection + +`AKSection` controls the sections and their order in the AboutKit form. The standard order is available as `AKSection.defaultOrder`. A custom section can contain one or more `AKEntry` values: + +```swift +let sections: [AKSection] = [ + .header, + .contact, + .custom( + title: "Purchases", + [purchaseEntry, tipJarEntry] + ), + .developerProfiles, + .appProfiles, + .sharingAndReview, + .legalAndAcknowledgements, + .testFlight, + .otherApps +] +``` + +Omit `title` for an untitled custom section: `.custom([purchaseEntry])`. + +Pass the order to `AKConfiguration` using its `sections` parameter. + +### AKEntry + +`AKEntry` creates a row with a title and SF Symbol that presents any SwiftUI destination using a navigation push or sheet. Both are available on iOS, watchOS, tvOS and visionOS; see [Platform differences](#platform-differences) for macOS: + +```swift +let purchaseEntry = AKEntry.navigation( + "Purchase Options", + systemImage: "cart" +) { + PurchaseOptionsView() +} + +let tipJarEntry = AKEntry.sheet( + "Tip Jar", + systemImage: "heart" +) { + TipJarView() +} +``` + +Sheet destinations are responsible for providing any navigation container or dismissal controls they require. + +#### Platform differences + +On macOS the form has no navigation stack to push onto, so `.navigation` and `.sheet` behave identically: both present their destination in a sheet. Rows also match the surrounding macOS style, showing the title alongside a trailing action button rather than an SF Symbol, so `systemImage` is ignored there. + +This means every destination needs its own dismissal control on macOS, including those created with `.navigation`. Sheets are given a minimum size of 400×300, matching the built-in acknowledgements sheet; a destination that sets its own frame overrides this. + +```swift +struct PurchaseOptionsView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + #if os(macOS) + NavigationStack { + content + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + } + } + #else + content + #endif + } +} +``` + ### AKMyApp This is a struct containing details about the current app. It can be created like so: diff --git a/Sources/AboutKit/AboutAppView.swift b/Sources/AboutKit/AboutAppView.swift index 921e237..43e1533 100644 --- a/Sources/AboutKit/AboutAppView.swift +++ b/Sources/AboutKit/AboutAppView.swift @@ -27,10 +27,29 @@ public struct AboutAppView: View { public var body: some View { Form { + ForEach(Array(configuration.sections.enumerated()), id: \.offset) { _, section in + content(for: section) + } + } + .navigationTitle(LocalizedStrings.aboutApp) + .sheet(isPresented: $showingMailSheet) { + MailView(app: configuration.app, debugDetails: AboutKit.debugDetails) + .edgesIgnoringSafeArea(.all) + } + } + + + // MARK: - Sections + + @ViewBuilder + private func content(for section: AKSection) -> some View { + switch section { + case .header: Section { HeaderView(app: configuration.app) } + case .contact: if configuration.app.email != nil || configuration.app.websiteURL != nil { Section { Button(action: sendMail) { @@ -39,7 +58,7 @@ public struct AboutAppView: View { systemImage: "envelope" ) } - + if let websiteURL = configuration.app.websiteURL { Button { openURL(websiteURL) @@ -53,6 +72,7 @@ public struct AboutAppView: View { } } + case .developerProfiles: if configuration.app.developer.profiles.isEmpty == false { Section { ForEach( @@ -71,6 +91,7 @@ public struct AboutAppView: View { } } + case .appProfiles: if configuration.app.profiles.isEmpty == false { Section { ForEach( @@ -88,7 +109,8 @@ public struct AboutAppView: View { } } } - + + case .sharingAndReview: if configuration.showShareApp.isVisible || configuration.showWriteReview.isVisible { Section { if configuration.showShareApp.isVisible { @@ -116,6 +138,7 @@ public struct AboutAppView: View { } } + case .legalAndAcknowledgements: if configuration.app.privacyPolicyURL != nil || configuration.app.termsOfUseURL != nil || configuration.app.acknowledgements?.frameworks?.isEmpty == false || configuration.app.acknowledgements?.people?.isEmpty == false { Section { if let privacyPolicyURL = configuration.app.privacyPolicyURL { @@ -155,6 +178,7 @@ public struct AboutAppView: View { } } + case .testFlight: if let testFlightURL = configuration.app.testFlightURL { Section { Button { @@ -168,6 +192,7 @@ public struct AboutAppView: View { } } + case .otherApps: if configuration.otherApps.isEmpty == false { Section { ForEach(configuration.otherApps, content: OtherAppRowView.init) @@ -177,16 +202,24 @@ public struct AboutAppView: View { } label: { Text(LocalizedStrings.viewAllApps) } - + } header: { Text(LocalizedStrings.otherApps) } } - } - .navigationTitle(LocalizedStrings.aboutApp) - .sheet(isPresented: $showingMailSheet) { - MailView(app: configuration.app, debugDetails: AboutKit.debugDetails) - .edgesIgnoringSafeArea(.all) + + case .custom(let title, let entries): + if entries.isEmpty == false { + Section { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + entry + } + } header: { + if let title { + Text(title) + } + } + } } } diff --git a/Sources/AboutKit/MacAboutAppView.swift b/Sources/AboutKit/MacAboutAppView.swift index 86074d0..064f354 100644 --- a/Sources/AboutKit/MacAboutAppView.swift +++ b/Sources/AboutKit/MacAboutAppView.swift @@ -26,11 +26,32 @@ public struct AboutAppView: View { public var body: some View { Form { + ForEach(Array(configuration.sections.enumerated()), id: \.offset) { _, section in + content(for: section) + } + } + .formStyle(.grouped) + .navigationTitle(LocalizedStrings.aboutApp) + .sheet(isPresented: $showingAcknowledgements) { + if let acknowledgements = configuration.app.acknowledgements { + AcknowledgementsView(acknowledgements) + } + } + } + + + // MARK: - Sections + + @ViewBuilder + private func content(for section: AKSection) -> some View { + switch section { + case .header: Section { HeaderView(app: configuration.app) .padding(.vertical, 8) } + case .contact: if configuration.app.email != nil || configuration.app.websiteURL != nil { Section { ItemLabel( @@ -38,7 +59,7 @@ public struct AboutAppView: View { actionTitle: LocalizedStrings.contactDeveloper, action: sendMail ) - + if let websiteURL = configuration.app.websiteURL { ItemLabel( LocalizedStrings.website, @@ -50,6 +71,7 @@ public struct AboutAppView: View { } } + case .developerProfiles: if configuration.app.developer.profiles.isEmpty == false { Section { ForEach( @@ -66,6 +88,7 @@ public struct AboutAppView: View { } } + case .appProfiles: if configuration.app.profiles.isEmpty == false { Section { ForEach( @@ -82,6 +105,7 @@ public struct AboutAppView: View { } } + case .sharingAndReview: if configuration.showShareApp.isVisible || configuration.showWriteReview.isVisible { Section { if configuration.showShareApp.isVisible { @@ -109,6 +133,7 @@ public struct AboutAppView: View { } } + case .legalAndAcknowledgements: if configuration.app.privacyPolicyURL != nil || configuration.app.termsOfUseURL != nil || configuration.app.acknowledgements?.frameworks?.isEmpty == false || configuration.app.acknowledgements?.people?.isEmpty == false { Section { if let privacyPolicyURL = configuration.app.privacyPolicyURL { @@ -142,6 +167,7 @@ public struct AboutAppView: View { } } + case .testFlight: if let testFlightURL = configuration.app.testFlightURL { Section { ItemLabel( @@ -153,6 +179,7 @@ public struct AboutAppView: View { } } + case .otherApps: if configuration.otherApps.isEmpty == false { Section { ForEach(configuration.otherApps, content: OtherAppRowView.init) @@ -168,12 +195,18 @@ public struct AboutAppView: View { Text(LocalizedStrings.otherApps) } } - } - .formStyle(.grouped) - .navigationTitle(LocalizedStrings.aboutApp) - .sheet(isPresented: $showingAcknowledgements) { - if let acknowledgements = configuration.app.acknowledgements { - AcknowledgementsView(acknowledgements) + + case .custom(let title, let entries): + if entries.isEmpty == false { + Section { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + entry + } + } header: { + if let title { + Text(title) + } + } } } } diff --git a/Sources/AboutKit/Models/AKConfiguration.swift b/Sources/AboutKit/Models/AKConfiguration.swift index e7b1055..7fd53b2 100644 --- a/Sources/AboutKit/Models/AKConfiguration.swift +++ b/Sources/AboutKit/Models/AKConfiguration.swift @@ -8,7 +8,7 @@ import Foundation /// A custom struct containing details for AboutKit. -public struct AKConfiguration: Sendable { +public struct AKConfiguration { /// A custom struct of type `AKMyApp` containing details about the current app. public let app: AKMyApp @@ -22,6 +22,9 @@ public struct AKConfiguration: Sendable { /// Indicates whether to show the Write Review option. public let showWriteReview: AKShowOption + /// The ordered sections displayed in the AboutKit form. + public let sections: [AKSection] + /// Initializes a new `AKConfiguration` struct which contains details about the /// current app and other apps. /// - Parameters: @@ -29,19 +32,23 @@ public struct AKConfiguration: Sendable { /// - otherApps: An array of `AKOtherApp` that contains details about other apps the developer owns. /// - showShareApp: Indicates whether to show the Share App option. Defaults to `.always`. /// - showWriteReview: Indicates whether to show the Write Review option. Defaults to `.always`. + /// - sections: The ordered sections displayed in the AboutKit form. Defaults to `AKSection.defaultOrder`. public init( app: AKMyApp, otherApps: [AKOtherApp], showShareApp: AKShowOption = .always, - showWriteReview: AKShowOption = .always + showWriteReview: AKShowOption = .always, + sections: [AKSection] = AKSection.defaultOrder ) { self.app = app self.otherApps = otherApps self.showShareApp = showShareApp self.showWriteReview = showWriteReview + self.sections = sections } /// An example `AKConfiguration` to be used in SwiftUI previews. + @MainActor static let example = AKConfiguration( app: .example, otherApps: [.example], diff --git a/Sources/AboutKit/Models/AKEntry.swift b/Sources/AboutKit/Models/AKEntry.swift new file mode 100644 index 0000000..045e23c --- /dev/null +++ b/Sources/AboutKit/Models/AKEntry.swift @@ -0,0 +1,185 @@ +// +// AKEntry.swift +// AboutKit +// + +import SwiftUI + +/// A custom row that presents a SwiftUI view from an AboutKit section. +public struct AKEntry: View { + + /// The way an entry presents its destination. + enum Presentation: Equatable { + /// Pushes the destination onto the current navigation stack. + case navigation + + /// Presents the destination in a sheet. + case sheet + } + + /// The way this entry presents its destination. + let presentation: Presentation + + /// The title displayed in the row. + let title: String + + /// The name of the SF Symbol displayed alongside the title. + let systemImage: String + + /// The type erased destination presented by the row. + private let destination: AnyView + + private init( + presentation: Presentation, + title: String, + systemImage: String, + destination: AnyView + ) { + self.presentation = presentation + self.title = title + self.systemImage = systemImage + self.destination = destination + } + + /// Creates a row that pushes its destination onto the current navigation stack. + /// - Parameters: + /// - title: The title displayed in the row. + /// - systemImage: The name of the SF Symbol displayed alongside the title. + /// - destination: A view builder producing the view to push. + @MainActor + public static func navigation( + _ title: String, + systemImage: String, + @ViewBuilder destination: () -> Destination + ) -> AKEntry { + AKEntry( + presentation: .navigation, + title: title, + systemImage: systemImage, + destination: AnyView(destination()) + ) + } + + /// Creates a row that presents its destination in a sheet. + /// - Parameters: + /// - title: The title displayed in the row. + /// - systemImage: The name of the SF Symbol displayed alongside the title. + /// - destination: A view builder producing the view to present. + @MainActor + public static func sheet( + _ title: String, + systemImage: String, + @ViewBuilder destination: () -> Destination + ) -> AKEntry { + AKEntry( + presentation: .sheet, + title: title, + systemImage: systemImage, + destination: AnyView(destination()) + ) + } + + @ViewBuilder + public var body: some View { + #if os(macOS) + // The macOS form has no navigation stack to push onto, so every entry + // presents its destination in a sheet regardless of its presentation. + AKSheetEntry( + title: title, + systemImage: systemImage, + destination: destination + ) + #else + switch presentation { + case .navigation: + NavigationLink { + destination + } label: { + AKEntryLabel(title: title, systemImage: systemImage) + } + + case .sheet: + AKSheetEntry( + title: title, + systemImage: systemImage, + destination: destination + ) + } + #endif + } +} + +private struct AKSheetEntry: View { + let title: String + let systemImage: String + let destination: AnyView + + @State private var isPresented = false + + var body: some View { + row + .sheet(isPresented: $isPresented) { + #if os(macOS) + // A macOS sheet is sized by its content, which leaves flexible + // destinations such as forms and lists too small. The floor + // matches the built-in acknowledgements sheet, and a + // destination setting its own frame still wins. + destination + .frame(minWidth: 400, minHeight: 300) + #else + destination + #endif + } + } + + @ViewBuilder + private var row: some View { + #if os(macOS) + // Matches the built-in macOS rows, which are a title alongside a + // trailing action button and carry no symbol. + ItemLabel(title, actionTitle: LocalizedStrings.viewMac) { + isPresented = true + } + #else + Button { + isPresented = true + } label: { + AKEntryLabel(title: title, systemImage: systemImage) + } + #endif + } +} + +#if !os(macOS) +private struct AKEntryLabel: View { + let title: String + let systemImage: String + + var body: some View { + #if os(iOS) || os(visionOS) + ItemLabel(title, systemImage: systemImage) + #else + Label(title, systemImage: systemImage) + #endif + } +} +#endif + +#Preview { + NavigationStack { + Form { + Section("Custom") { + AKEntry.navigation("Navigation", systemImage: "arrow.right") { + Text("Navigation destination") + } + + AKEntry.sheet("Sheet", systemImage: "rectangle.portrait") { + Text("Sheet destination") + } + } + } + #if os(macOS) + .formStyle(.grouped) + #endif + } +} diff --git a/Sources/AboutKit/Models/AKSection.swift b/Sources/AboutKit/Models/AKSection.swift new file mode 100644 index 0000000..6701eef --- /dev/null +++ b/Sources/AboutKit/Models/AKSection.swift @@ -0,0 +1,31 @@ +// +// AKSection.swift +// AboutKit +// + +/// A section displayed in the AboutKit form. +public enum AKSection { + case header + case contact + case developerProfiles + case appProfiles + case sharingAndReview + case legalAndAcknowledgements + case testFlight + case otherApps + case custom(title: String? = nil, _ entries: [AKEntry]) + + /// The section order used when no custom order is supplied. + public static var defaultOrder: [AKSection] { + [ + .header, + .contact, + .developerProfiles, + .appProfiles, + .sharingAndReview, + .legalAndAcknowledgements, + .testFlight, + .otherApps + ] + } +} diff --git a/Sources/AboutKit/WatchTVAboutAppView.swift b/Sources/AboutKit/WatchTVAboutAppView.swift index c0ec097..defa93a 100644 --- a/Sources/AboutKit/WatchTVAboutAppView.swift +++ b/Sources/AboutKit/WatchTVAboutAppView.swift @@ -22,11 +22,26 @@ public struct AboutAppView: View { public var body: some View { Form { + ForEach(Array(configuration.sections.enumerated()), id: \.offset) { _, section in + content(for: section) + } + } + .navigationTitle(LocalizedStrings.aboutApp) + } + + + // MARK: - Sections + + @ViewBuilder + private func content(for section: AKSection) -> some View { + switch section { + case .header: Section { HeaderView(app: configuration.app) .focusable() } + case .contact: if configuration.app.email != nil || configuration.app.websiteURL != nil { Section { if let email = configuration.app.email { @@ -45,6 +60,7 @@ public struct AboutAppView: View { } } + case .developerProfiles: if configuration.app.developer.profiles.isEmpty == false { Section { ForEach( @@ -59,6 +75,7 @@ public struct AboutAppView: View { } } + case .appProfiles: if configuration.app.profiles.isEmpty == false { Section { ForEach( @@ -72,7 +89,11 @@ public struct AboutAppView: View { } } } - + + case .sharingAndReview: + EmptyView() + + case .legalAndAcknowledgements: if configuration.app.privacyPolicyURL != nil || configuration.app.termsOfUseURL != nil || configuration.app.acknowledgements?.frameworks?.isEmpty == false || configuration.app.acknowledgements?.people?.isEmpty == false { Section { if let privacyPolicyURL = configuration.app.privacyPolicyURL { @@ -99,6 +120,7 @@ public struct AboutAppView: View { } } + case .testFlight: if let testFlightURL = configuration.app.testFlightURL { Section { ItemLabel( @@ -108,6 +130,7 @@ public struct AboutAppView: View { } } + case .otherApps: if configuration.otherApps.isEmpty == false { Section { ForEach( @@ -127,8 +150,24 @@ public struct AboutAppView: View { #endif } } + + case .custom(let title, let entries): + if entries.isEmpty == false { + Section { + ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in + entry + } + } header: { + if let title { + #if os(tvOS) + SectionHeaderLabel(title) + #else + Text(title) + #endif + } + } + } } - .navigationTitle(LocalizedStrings.aboutApp) } } diff --git a/Tests/AboutKitTests/AboutKitTests.swift b/Tests/AboutKitTests/AboutKitTests.swift index c651eb1..94a8641 100644 --- a/Tests/AboutKitTests/AboutKitTests.swift +++ b/Tests/AboutKitTests/AboutKitTests.swift @@ -1,14 +1,91 @@ import XCTest @testable import AboutKit +import SwiftUI final class AboutKitTests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. + func testDefaultSectionOrder() { + let sectionNames = AKSection.defaultOrder.map { section in + switch section { + case .header: "header" + case .contact: "contact" + case .developerProfiles: "developerProfiles" + case .appProfiles: "appProfiles" + case .sharingAndReview: "sharingAndReview" + case .legalAndAcknowledgements: "legalAndAcknowledgements" + case .testFlight: "testFlight" + case .otherApps: "otherApps" + case .custom: "custom" + } + } + + XCTAssertEqual( + sectionNames, + [ + "header", + "contact", + "developerProfiles", + "appProfiles", + "sharingAndReview", + "legalAndAcknowledgements", + "testFlight", + "otherApps" + ] + ) + + MainActor.assumeIsolated { + XCTAssertEqual(AKConfiguration.example.sections.count, sectionNames.count) + } + } + + func testCustomSectionEntries() { + MainActor.assumeIsolated { + let navigationEntry = AKEntry.navigation( + "Purchases", + systemImage: "cart" + ) { + Text("Purchases") + } + + let sheetEntry = AKEntry.sheet( + "Tip Jar", + systemImage: "heart" + ) { + Text("Tip Jar") + } + + let section = AKSection.custom( + title: "Support", + [navigationEntry, sheetEntry] + ) + + guard case .custom(let title, let entries) = section else { + return XCTFail("Expected a custom section") + } + + XCTAssertEqual(title, "Support") + XCTAssertEqual(entries.count, 2) + + XCTAssertEqual(entries[0].presentation, .navigation) + XCTAssertEqual(entries[0].title, "Purchases") + XCTAssertEqual(entries[0].systemImage, "cart") + + XCTAssertEqual(entries[1].presentation, .sheet) + XCTAssertEqual(entries[1].title, "Tip Jar") + XCTAssertEqual(entries[1].systemImage, "heart") + + let untitledSection = AKSection.custom([navigationEntry]) + + guard case .custom(let title, let entries) = untitledSection else { + return XCTFail("Expected an untitled custom section") + } + + XCTAssertNil(title) + XCTAssertEqual(entries.count, 1) + } } - static var allTests = [ - ("testExample", testExample), + static let allTests = [ + ("testDefaultSectionOrder", testDefaultSectionOrder), + ("testCustomSectionEntries", testCustomSectionEntries), ] }