A modern, modular iOS SDK for collecting and submitting user feedback with support for Jira, custom backends, and optional AI-powered description enhancement.
- ๐ฏ Protocol-based architecture - Easy to extend with custom providers
- ๐ Multiple providers - Built-in support for Jira, with the ability to add custom backends
- ๐ค Optional AI enhancement - Improve feedback descriptions with OpenAI GPT or Anthropic Claude
- ๐จ SwiftUI + TCA - Beautiful, reactive UI built with Composable Architecture
- ๐ธ Screenshot capture - Automatically attach screenshots to feedback
- ๐งช Full test coverage - Comprehensive unit tests for all components
- ๐ Secure by default - No hardcoded API keys, environment-based configuration
- ๐จ Themeable UI - Customize colors, fonts, and styling
Add FeedbackKit to your project using Xcode:
- File โ Add Package Dependencies
- Enter:
https://github.com/yourorg/FeedbackKit - Select the modules you need:
FeedbackKit- All-in-one (includes Core + UI)FeedbackKitCore- Just the protocols and modelsFeedbackKitJira- Jira integrationFeedbackKitAI- AI enhancement (OpenAI, Claude)FeedbackKitUI- SwiftUI views and TCA features
Or add to your Package.swift:
dependencies: [
.package(url: "https://github.com/yourorg/FeedbackKit", from: "1.0.0")
]The simplest way to get started is with the NoOp provider, which logs feedback to the console:
import SwiftUI
import FeedbackKit
struct ContentView: View {
@State private var showFeedback = false
var body: some View {
Button("Send Feedback") {
showFeedback = true
}
.sheet(isPresented: $showFeedback) {
FeedbackView(
store: Store(initialState: FeedbackFeature.State()) {
FeedbackFeature()
}
)
}
}
}To submit feedback to Jira, configure the Jira provider with your credentials:
import FeedbackKit
import FeedbackKitJira
struct ContentView: View {
@State private var showFeedback = false
var body: some View {
Button("Send Feedback") {
showFeedback = true
}
.sheet(isPresented: $showFeedback) {
FeedbackView(
store: Store(initialState: FeedbackFeature.State()) {
FeedbackFeature()
} withDependencies: {
$0.feedbackProvider = makeJiraProvider()
}
)
}
}
func makeJiraProvider() -> JiraProvider {
let config = JiraConfiguration(
baseURL: URL(string: "https://your-company.atlassian.net")!,
email: ProcessInfo.processInfo.environment["JIRA_EMAIL"]!,
apiToken: ProcessInfo.processInfo.environment["JIRA_API_TOKEN"]!,
projectKey: "PROJ",
issueType: "Bug"
)
return JiraProvider(configuration: config)
}
}Add AI-powered description improvement:
import FeedbackKit
import FeedbackKitJira
import FeedbackKitAI
FeedbackView(
store: Store(initialState: FeedbackFeature.State()) {
FeedbackFeature()
} withDependencies: {
$0.feedbackProvider = JiraProvider(configuration: jiraConfig)
$0.descriptionEnhancer = OpenAIEnhancer(
configuration: .openAI(
apiKey: ProcessInfo.processInfo.environment["OPENAI_API_KEY"]!
)
)
}
)Use Anthropic Claude instead of OpenAI:
import FeedbackKitAI
$0.descriptionEnhancer = AnthropicEnhancer(
configuration: .anthropic(
apiKey: ProcessInfo.processInfo.environment["ANTHROPIC_API_KEY"]!
)
)Create your own feedback backend by implementing FeedbackProvider:
import FeedbackKitCore
struct SlackProvider: FeedbackProvider {
let webhookURL: URL
func submit(
_ feedback: FeedbackItem,
metadata: FeedbackMetadata
) async throws -> FeedbackResult {
let payload = [
"text": """
*\(feedback.title)*
\(feedback.description)
Device: \(metadata.deviceModel)
Version: \(metadata.appVersion)
"""
]
var request = URLRequest(url: webhookURL)
request.httpMethod = "POST"
request.httpBody = try JSONSerialization.data(withJSONObject: payload)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw FeedbackError.submissionFailed("Slack webhook failed")
}
return FeedbackResult(
identifier: UUID().uuidString,
url: nil,
providerName: "Slack"
)
}
}
// Usage:
$0.feedbackProvider = SlackProvider(
webhookURL: URL(string: "https://hooks.slack.com/...")!
)The recommended approach for API keys is environment variables:
# Jira
export JIRA_BASE_URL="https://your-company.atlassian.net"
export JIRA_EMAIL="your-email@company.com"
export JIRA_API_TOKEN="your-api-token"
export JIRA_PROJECT_KEY="PROJ"
# OpenAI
export OPENAI_API_KEY="sk-..."
# Anthropic
export ANTHROPIC_API_KEY="sk-ant-..."Then load from environment:
let jiraConfig = try JiraConfiguration.fromEnvironment()
let aiConfig = try AIConfiguration.fromEnvironment()Add custom metadata fields to your feedback:
struct CustomMetadataCollector: MetadataCollector {
func collect() async -> FeedbackMetadata {
let default = await DefaultMetadataCollector().collect()
return FeedbackMetadata(
appVersion: default.appVersion,
appBuild: default.appBuild,
deviceModel: default.deviceModel,
osVersion: default.osVersion,
locale: default.locale,
customFields: [
"environment": "Production",
"userId": UserDefaults.standard.string(forKey: "userId") ?? "Unknown",
"subscription": "Premium"
]
)
}
}
// Usage:
$0.metadataCollector = CustomMetadataCollector()Customize the UI appearance:
struct MyTheme: FeedbackTheme {
var primaryColor: Color { .purple }
var backgroundColor: Color { .black }
var cardColor: Color { Color(.systemGray6) }
var textColor: Color { .white }
var secondaryTextColor: Color { .gray }
var cornerRadius: CGFloat { 16 }
var buttonFont: Font { .headline }
var titleFont: Font { .largeTitle.bold() }
var bodyFont: Font { .body }
}
FeedbackView(
store: ...,
theme: MyTheme()
)Add to .gitignore:
.env
Config.plist
Secrets/
-
Environment Variables (Development)
ProcessInfo.processInfo.environment["API_KEY"]
-
Xcode Configuration Files (CI/CD)
- Use
.xcconfigfiles - Set in Xcode schemes
- Use
-
Backend Proxy (Production - Most Secure)
- Client calls your backend
- Backend stores credentials securely
- Backend proxies to Jira/OpenAI
Example backend proxy:
struct ProxyProvider: FeedbackProvider {
let backendURL: URL
func submit(_ feedback: FeedbackItem, metadata: FeedbackMetadata) async throws -> FeedbackResult {
// Send to your backend, which then calls Jira/AI APIs
// This way, API keys never leave your server
}
}FeedbackKit uses a protocol-based architecture that separates concerns:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ FeedbackKitUI (Views) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ FeedbackView โ โ
โ โ FeedbackFeature (TCA) โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ
โ depends on
โโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโ
โ FeedbackKitCore (Protocols) โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ FeedbackProvider โ โ
โ โ DescriptionEnhancer โ โ
โ โ MetadataCollector โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโ
โ implemented by
โโโโโโโโโโโดโโโโโโโโโโโ
โผ โผ
โโโโโโโโโโโโโโโ--โโ โโ-โโโ----โโโโโโโโโโโ
โ FeedbackKitJira โ โ FeedbackKitAI โ
โ JiraProvider โ โ OpenAIEnhancer โ
โ โ โ AnthropicEnhancer โ
โโโโโโโโโโโโโโโ-โ-โ โโ----โโโโโโโโโโโโโ-โ
FeedbackKit includes test helpers for easy testing:
import XCTest
import ComposableArchitecture
import FeedbackKit
@Test func testSubmitFeedback() async {
let store = TestStore(initialState: FeedbackFeature.State()) {
FeedbackFeature()
} withDependencies: {
$0.feedbackProvider = MockProvider()
$0.metadataCollector = MockMetadataCollector()
}
await store.send(.sendFeedback) {
$0.isSending = true
}
await store.receive(.feedbackResponse(.success(mockResult))) {
$0.isSending = false
$0.isSuccess = true
}
}- iOS 17.0+
- Swift 5.9+
- Xcode 15.0+
- swift-composable-architecture (1.18.0+)
- swift-dependencies (1.8.0+)
MIT License
Contributions are welcome! Please feel free to submit a Pull Request.
Created for Shutterfly's hackday and transformed into a reusable SDK.
For issues and questions:
- Open an issue on GitHub
- Check the documentation