| 🗳️ A lightweight native Swift SDK to collect suggestions, feedback and votes directly within your iOS, iPadOS, macOS and tvOS app. |
|---|
Votice is a native Swift SDK that allows you to integrate user feedback, suggestion boards, issues, and voting mechanisms in your app with a clean UI and a simple setup. It connects to a custom backend using HMAC authentication and does not require Firebase or other configurations.
The Votice management app for handling suggestions or issues and apps is available for download on the App Store:
Available for: iOS, iPadOS and macOS.
- iOS 17+ / iPadOS 17+ / macOS 14+ / tvOS 17+
- Swift 5.0+
- SwiftUI-based project
- Votice backend properly configured (API key + secret)
Add this line to your Package.swift dependencies:
.package(url: "https://github.com/artcc/votice-sdk", from: "1.0.19"),Or via Xcode:
- Open your project.
- Go to File > Add Packages...
- Enter the URL of the Votice repo: https://github.com/artcc/votice-sdk
- Choose the latest version.
// swift-tools-version:5.9
let package = Package(
name: "Votice",
platforms: [
.iOS(.v17),
.macOS(.v14),
.tvOS(.v17)
],
products: [
.library(
name: "VoticeSDK",
targets: ["VoticeSDK"]
)
],
targets: [
.target(
name: "VoticeSDK",
path: "Sources/Votice"
),
.testTarget(
name: "VoticeTests",
dependencies: ["VoticeSDK"],
path: "Tests/VoticeTests"
)
]
)Before using any Votice component, configure it with your app credentials:
import VoticeSDK
try Votice.configure(
apiKey: "your-api-key",
apiSecret: "your-api-secret",
appId: "your-app-id"
)You can reset or check configuration status:
Votice.reset()
let configured = Votice.isConfiguredYou can embed the main interface as a SwiftUI view:
Votice.feedbackView()Or present it as a modal sheet:
Votice.feedbackSheet(isPresented: $isShowingFeedback)Or use a NavigationLink:
NavigationLink {
Votice.feedbackNavigationView())
} label: {
Text("Navigate to Feedback")
}Use the default system-adaptive theme:
let theme = Votice.systemTheme()Or create your own theme:
let theme = Votice.createTheme(
primaryColor: .blue,
backgroundColor: .white,
surfaceColor: .gray.opacity(0.1)
)Advanced configuration is also available:
let customTheme = Votice.createAdvancedTheme(
primaryColor: .purple,
accentColor: .pink,
backgroundColor: Color(.systemBackground),
surfaceColor: Color(.secondarySystemBackground),
destructiveColor: .red,
successColor: .mint,
warningColor: .orange) Then pass it into the view:
Votice.feedbackView(theme: theme)You can provide custom localized texts by conforming to VoticeTextsProtocol:
Votice.setTexts(VoticeTexts())Example:
struct VoticeTexts: VoticeTextsProtocol {
let cancel = String(localized: "Cancel")
let error = String(localized: "Error")
let ok = String(localized: "Ok")
let submit = String(localized: "Submit")
let optional = String(localized: "Optional")
let success = String(localized: "Success")
let warning = String(localized: "Warning")
let info = String(localized: "Information")
let genericError = String(localized: "Something went wrong. Please try again.")
let anonymous = String(localized: "Anonymous")
let loadingSuggestions = String(localized: "Loading suggestions...")
let noSuggestionsYet = String(localized: "No suggestions yet.")
let beFirstToSuggest = String(localized: "Be the first to suggest something!")...
}To reset to the default English:
Votice.resetTextsToDefault()Check this URL to learn how to localize your app https://gist.github.com/ArtCC/10a0eff42f1f62c305b28c15883b9b9f
You can customize all SDK interface fonts using your own fonts included in your project. This allows you to maintain visual consistency with your app's identity.
- Drag the font files (.ttf, .otf) into your Xcode project.
- Make sure to add them in the "Copy Bundle Resources" section of your target.
- Add the font names in the
Fonts provided by applicationfield in your Info.plist.
Create a font configuration and apply it to the SDK before displaying any Votice view:
let poppinsConfig = VoticeFontConfiguration(
fontFamily: "Poppins",
weights: [
.regular: "Poppins-Regular",
.medium: "Poppins-Medium",
.semiBold: "Poppins-SemiBold",
.bold: "Poppins-Bold"
]
)
Votice.setFonts(poppinsConfig)fontFamily: Name of the font family (must match the name registered in the system).weights: Dictionary with the exact names of each font weight variant.
If you want to revert to the default iOS font:
Votice.resetFontsToSystem()You can create a theme that respects the custom font:
let theme = Votice.createThemeWithCurrentFonts(primaryColor: .blue)Or use the system theme with the custom font:
let theme = Votice.systemThemeWithCurrentFonts()Then pass it to the feedback view:
Votice.feedbackView(theme: theme)ℹ️ If you do not configure custom fonts, the SDK will use the default system fonts.
You can allow users to comment on suggestions or issues by enabling the comments feature (Default is enabled):
Votice.setCommentIsEnabled(enabled: Bool)If you want to mark the current user as a premium user (Default is false), you can do so with:
Votice.setUserIsPremium(isPremium: Bool)By default, Votice SDK runs silently to avoid cluttering your development console. If you need to troubleshoot SDK issues or see internal operations, you can enable debug logging (Default is disabled):
Enable debug logging (useful for troubleshooting):
Votice.setDebugLogging(enabled: true)Check current logging status:
let isLoggingEnabled = Votice.isDebugLoggingEnabledDisable when no longer needed:
Votice.setDebugLogging(enabled: false)When to use debug logging:
- Troubleshooting network requests to your backend
- Verifying SDK configuration
- Understanding internal SDK behavior
- During development/testing phases
Note: Debug logging is automatically disabled in production builds and should only be enabled when specifically needed for debugging purposes.
You can choose to display suggestions or issues with status completed in their own tab. When enabled:
- A segmented control appears with two tabs: "Active" and "Completed".
- Completed suggestions or issues are removed from the main list.
- The "Completed" filter disappears from the filter menu (no longer needed).
- Completed suggestions or issues are only visible in the dedicated tab.
- If you don't enable it, the behavior remains the same as before.
Enable:
Votice.setShowCompletedSeparately(enabled: true)Disable (returns to original behavior):
Votice.setShowCompletedSeparately(enabled: false)By default, all optional statuses (accepted, blocked, rejected) are visible along with the mandatory ones. You can choose which optional statuses to show in both the list and the filter menu.
Mandatory statuses (always shown):
completed(or in its separate tab if you enabled section 8)in-progresspending
Optional statuses (individually hideable):
acceptedblockedrejected
Configure which optional statuses are visible:
// Example: show accepted & rejected, hide blocked
Votice.setVisibleOptionalStatuses(accepted: true, blocked: false, rejected: true)Behavior:
- Hidden optional statuses are removed from the filter menu and never displayed in the list.
- If a previously selected (persisted) filter becomes hidden, it is automatically cleared.
- Works together with the "completed separately" mode (section 8). If that mode is active,
completedwill still not appear in filters, regardless of this configuration. - Defaults: all three optional statuses visible (equivalent to
true, true, true).
Use cases:
- Simplify the board for early product phases (e.g. only pending + in-progress + completed)
- Gradually introduce refinement states later (enable accepted / blocked / rejected)
Note: Calling this method multiple times replaces the previous configuration entirely.
You can opt-in to use Liquid Glass, Apple's modern design material that combines dynamic blur with light reflection effects, creating a fluid and immersive glass-like interface. When enabled, UI components like dropdowns, cards, and sheets will adopt this new visual style (Default is enabled).
Enable Liquid Glass:
Votice.setLiquidGlassEnabled(true)Disable (returns to the classic design):
Votice.setLiquidGlassEnabled(false)Platform requirements:
- iOS 26+ / iPadOS 26+ / macOS 26+ (Tahoe) / tvOS 26+
- On older OS versions, the SDK will automatically fall back to the classic design even if enabled
Benefits of Liquid Glass:
- Modern, fluid glass-like visual design
- Dynamic blur that adapts to content behind
- Light and color reflection from surrounding elements
- Interactive effects that respond to touch and pointer interactions
Default behavior:
- Disabled by default for maximum compatibility
- Developers must explicitly opt-in to use Liquid Glass
- Works seamlessly with custom themes
Note: Liquid Glass is a cutting-edge design feature introduced in Apple's latest OS versions. Test thoroughly with your app's design system before shipping to production.
If you need to interact with Votice functionality without using the built-in UI components, you can access the use cases directly. This is useful for building custom interfaces or integrating Votice into your own workflows.
Votice SDK exposes two main use cases:
- SuggestionUseCase: Manage suggestions, issues, votes, and images
- CommentUseCase: Manage comments on suggestions or issues
import VoticeSDK
// Get use case instances
let suggestionUseCase = Votice.getSuggestionUseCase()
let commentUseCase = Votice.getCommentUseCase()Retrieve paginated suggestions or issues from your app:
let pagination = PaginationRequest(
startAfter: nil, // For first page
pageLimit: 20 // Number of items per page
)
Task {
do {
let response = try await suggestionUseCase.fetchSuggestions(pagination: pagination)
print("Total suggestions: \(response.count)")
for suggestion in response.suggestions {
print("- \(suggestion.title ?? ""): \(suggestion.voteCount ?? 0) votes")
}
// For next page, use the token
if let nextPage = response.nextPageToken {
let nextPagination = PaginationRequest(
startAfter: StartAfterRequest(
voteCount: nextPage.voteCount,
createdAt: nextPage.createdAt
),
pageLimit: 20
)
// Fetch next page...
}
} catch {
print("Error fetching suggestions: \(error)")
}
}Create a new suggestion or issue:
let request = CreateSuggestionRequest(
title: "Add dark mode",
description: "It would be great to have a dark theme option",
nickname: "John Doe", // Optional: user's display name
userIsPremium: true, // Whether user has premium
issue: false, // true for issues, false for suggestions
urlImage: nil // Optional: image URL (use uploadImage first)
)
Task {
do {
let response = try await suggestionUseCase.createSuggestion(request: request)
print("Suggestion created: \(response.suggestion.id)")
} catch {
print("Error creating suggestion: \(error)")
}
}Upload an image to attach to a suggestion or issue:
#if canImport(UIKit)
import UIKit
// Convert UIImage to base64
guard let image = UIImage(named: "screenshot"),
let imageData = image.jpegData(compressionQuality: 0.8) else {
return
}
let base64String = imageData.base64EncodedString()
let request = UploadImageRequest(
imageData: base64String,
fileName: "screenshot.jpg",
mimeType: "image/jpeg"
)
Task {
do {
let response = try await suggestionUseCase.uploadImage(request: request)
// Use response.imageUrl in CreateSuggestionRequest
print("Image uploaded: \(response.imageUrl)")
} catch {
print("Error uploading image: \(error)")
}
}
#endifUpvote or downvote a suggestion or issue:
Task {
do {
// Upvote
let response = try await suggestionUseCase.vote(
suggestionId: "suggestion-id-here",
voteType: .upvote
)
print("Vote registered: \(response.message)")
// To remove vote (downvote)
let removeResponse = try await suggestionUseCase.vote(
suggestionId: "suggestion-id-here",
voteType: .downvote
)
} catch {
print("Error voting: \(error)")
}
}Check if the current device has already voted:
Task {
do {
let status = try await suggestionUseCase.fetchVoteStatus(
suggestionId: "suggestion-id-here"
)
print("Has voted: \(status.hasVoted)")
print("Total votes: \(status.voteCount)")
} catch {
print("Error fetching vote status: \(error)")
}
}Delete a suggestion or issue created by the current device:
Task {
do {
let response = try await suggestionUseCase.deleteSuggestion(
suggestionId: "suggestion-id-here"
)
print("Suggestion deleted: \(response.message)")
} catch {
print("Error deleting suggestion: \(error)")
}
}Save and retrieve filter preferences locally:
// Save filter
try suggestionUseCase.setFilterApplied(.inProgress)
// Get current filter
let currentFilter = try suggestionUseCase.fetchFilterApplied()
print("Current filter: \(currentFilter?.rawValue ?? "none")")
// Clear filter
try suggestionUseCase.clearFilterApplied()Retrieve comments for a suggestion or issue:
let pagination = PaginationRequest(
startAfter: nil, // For first page
pageLimit: 50
)
Task {
do {
let response = try await commentUseCase.fetchComments(
suggestionId: "suggestion-id-here",
pagination: pagination
)
print("Total comments: \(response.comments.count)")
for comment in response.comments {
print("- \(comment.displayName): \(comment.text)")
}
} catch {
print("Error fetching comments: \(error)")
}
}Add a comment to a suggestion or issue:
Task {
do {
let response = try await commentUseCase.createComment(
suggestionId: "suggestion-id-here",
text: "Great idea! I'd love to see this feature.",
nickname: "Jane Doe" // Optional: user's display name
)
print("Comment created: \(response.comment.id)")
} catch {
print("Error creating comment: \(error)")
}
}Delete a comment created by the current device:
Task {
do {
try await commentUseCase.deleteComment(commentId: "comment-id-here")
print("Comment deleted successfully")
} catch {
print("Error deleting comment: \(error)")
}
}// Suggestion entity (used for both suggestions and issues)
public struct SuggestionEntity {
public let id: String
public let title: String?
public let description: String?
public let nickname: String?
public let status: SuggestionStatusEntity?
public let voteCount: Int?
public let commentCount: Int?
public let createdAt: String?
public let issue: Bool? // true for issues, false for suggestions
public let urlImage: String?
// ... more properties
}
// Comment entity
public struct CommentEntity {
public let id: String
public let text: String
public let nickname: String?
public let createdAt: String?
public var displayName: String // Returns nickname or "Anonymous"
// ... more properties
}
// Vote types
public enum VoteType: String {
case upvote
case downvote
}
// Suggestion statuses (applies to both suggestions and issues)
public enum SuggestionStatusEntity: String {
case pending
case accepted
case inProgress = "in-progress"
case completed
case rejected
case blocked
}// Suggestions response (includes both suggestions and issues)
public struct SuggestionsResponse {
public let suggestions: [SuggestionEntity]
public let count: Int
public let nextPageToken: NextPageResponse?
}
// Comments response
public struct CommentsResponse {
public let comments: [CommentEntity]
// ... more properties
}
// Create responses
public struct CreateSuggestionResponse {
public let message: String
public let suggestion: SuggestionEntity
}
public struct CreateCommentResponse {
public let message: String
public let comment: CommentEntity
}-
Always configure the SDK first:
try Votice.configure(apiKey: "...", apiSecret: "...", appId: "...")
-
Handle errors appropriately:
do { let response = try await suggestionUseCase.fetchSuggestions(pagination: pagination) // Handle success } catch let error as VoticeError { // Handle Votice-specific errors } catch { // Handle general errors }
-
Use async/await properly:
- All use case methods are async
- Wrap calls in
Taskwhen calling from sync context - Use proper error handling with try/catch
-
Pagination:
- Always specify a reasonable
pageLimit(10-50 recommended) - Use
nextPageTokenfor subsequent pages - Check if
nextPageTokenis nil to detect the last page
- Always specify a reasonable
-
Device ID:
- The SDK automatically manages device identification
- Users can only delete their own suggestions/issues/comments
- Device ID persists across app launches
Here's a complete example of building a custom suggestion list (works for both suggestions and issues):
import SwiftUI
import VoticeSDK
struct CustomSuggestionListView: View {
@State private var suggestions: [SuggestionEntity] = []
@State private var isLoading = false
private let suggestionUseCase = Votice.getSuggestionUseCase()
var body: some View {
List(suggestions) { suggestion in
VStack(alignment: .leading) {
Text(suggestion.title ?? "")
.font(.headline)
Text("\(suggestion.voteCount ?? 0) votes")
.font(.caption)
.foregroundColor(.secondary)
}
.onTapGesture {
voteSuggestion(suggestion.id)
}
}
.overlay {
if isLoading {
ProgressView()
}
}
.task {
await loadSuggestions()
}
}
func loadSuggestions() async {
isLoading = true
defer { isLoading = false }
let pagination = PaginationRequest(startAfter: nil, pageLimit: 20)
do {
let response = try await suggestionUseCase.fetchSuggestions(
pagination: pagination
)
suggestions = response.suggestions
} catch {
print("Error: \(error)")
}
}
func voteSuggestion(_ id: String) {
Task {
do {
_ = try await suggestionUseCase.vote(
suggestionId: id,
voteType: .upvote
)
await loadSuggestions() // Reload to show updated count
} catch {
print("Error voting: \(error)")
}
}
}
}Thank you for your interest in contributing to Votice!
This guide will help you submit issues, propose changes, and open pull requests in a way that fits the project.
If you find a bug:
- Search issues first — it may already be reported.
- Open a new issue with:
- A clear title
- Steps to reproduce
- Expected and actual behavior
- SDK version and platform
We welcome feedback!
- If it's a major change, open an issue first to discuss.
- Make sure it aligns with the lightweight philosophy of the SDK.
Arturo Carretero Calvo
Votice SDK is available under the MIT license. See the LICENSE file for more info.
Arturo Carretero Calvo - 2025




