424 lines
13 KiB
Markdown
424 lines
13 KiB
Markdown
|
|
# KeyUI Components
|
||
|
|
|
||
|
|
The KeyUI components provide the user interface for the Keys for All system, including the main panel, activation views, and visual indicators.
|
||
|
|
|
||
|
|
## Main Components
|
||
|
|
|
||
|
|
### KeysForAllPanel
|
||
|
|
|
||
|
|
```swift
|
||
|
|
struct KeysForAllPanel: View {
|
||
|
|
@StateObject private var keyManager = KeyManager.shared
|
||
|
|
@State private var showActivation = false
|
||
|
|
@State private var showPurchase = false
|
||
|
|
@State private var selectedTab = 0
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
VStack(spacing: 0) {
|
||
|
|
// Header
|
||
|
|
KeysHeaderView(currentLevel: keyManager.currentLicenseLevel)
|
||
|
|
|
||
|
|
// Tab Selection
|
||
|
|
Picker("View", selection: $selectedTab) {
|
||
|
|
Text("Status").tag(0)
|
||
|
|
Text("Purchase").tag(1)
|
||
|
|
Text("Share").tag(2)
|
||
|
|
}
|
||
|
|
.pickerStyle(SegmentedPickerStyle())
|
||
|
|
.padding()
|
||
|
|
|
||
|
|
// Content
|
||
|
|
ScrollView {
|
||
|
|
switch selectedTab {
|
||
|
|
case 0:
|
||
|
|
LicenseStatusView()
|
||
|
|
case 1:
|
||
|
|
PurchaseOptionsView()
|
||
|
|
case 2:
|
||
|
|
KeySharingView()
|
||
|
|
default:
|
||
|
|
EmptyView()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.navigationTitle("Keys for All")
|
||
|
|
.toolbar {
|
||
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||
|
|
Button("Have a Key?") {
|
||
|
|
showActivation = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.sheet(isPresented: $showActivation) {
|
||
|
|
KeyActivationView()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### KeysHeaderView
|
||
|
|
|
||
|
|
```swift
|
||
|
|
struct KeysHeaderView: View {
|
||
|
|
let currentLevel: LicenseLevel
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
HStack {
|
||
|
|
VStack(alignment: .leading, spacing: 4) {
|
||
|
|
Text("Current License")
|
||
|
|
.font(.caption)
|
||
|
|
.foregroundColor(.secondary)
|
||
|
|
|
||
|
|
HStack {
|
||
|
|
Text(currentLevel.displayName)
|
||
|
|
.font(.title2)
|
||
|
|
.fontWeight(.semibold)
|
||
|
|
|
||
|
|
ForEach(0..<currentLevel.rawValue, id: \.self) { _ in
|
||
|
|
Image(systemName: "key.fill")
|
||
|
|
.foregroundColor(.accentColor)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Spacer()
|
||
|
|
|
||
|
|
// Visual indicator
|
||
|
|
ZStack {
|
||
|
|
Circle()
|
||
|
|
.fill(currentLevel.color.opacity(0.2))
|
||
|
|
.frame(width: 60, height: 60)
|
||
|
|
|
||
|
|
Image(systemName: currentLevel.icon)
|
||
|
|
.font(.title)
|
||
|
|
.foregroundColor(currentLevel.color)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.padding()
|
||
|
|
.background(Color(.secondarySystemBackground))
|
||
|
|
.cornerRadius(12)
|
||
|
|
.padding()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### LicenseStatusView
|
||
|
|
|
||
|
|
```swift
|
||
|
|
struct LicenseStatusView: View {
|
||
|
|
@ObservedObject private var featureGating = FeatureGating.shared
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
VStack(spacing: 16) {
|
||
|
|
// Current features
|
||
|
|
GroupBox {
|
||
|
|
VStack(alignment: .leading, spacing: 12) {
|
||
|
|
Label("Your Features", systemImage: "checkmark.circle.fill")
|
||
|
|
.font(.headline)
|
||
|
|
.foregroundColor(.green)
|
||
|
|
|
||
|
|
Divider()
|
||
|
|
|
||
|
|
ForEach(unlockedFeatures, id: \.self) { feature in
|
||
|
|
HStack {
|
||
|
|
Image(systemName: "checkmark")
|
||
|
|
.foregroundColor(.green)
|
||
|
|
Text(feature.displayName)
|
||
|
|
Spacer()
|
||
|
|
}
|
||
|
|
.font(.subheadline)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.padding(.vertical, 8)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Locked features
|
||
|
|
if !lockedFeatures.isEmpty {
|
||
|
|
GroupBox {
|
||
|
|
VStack(alignment: .leading, spacing: 12) {
|
||
|
|
Label("Available Upgrades", systemImage: "lock.fill")
|
||
|
|
.font(.headline)
|
||
|
|
.foregroundColor(.orange)
|
||
|
|
|
||
|
|
Divider()
|
||
|
|
|
||
|
|
ForEach(lockedFeatures, id: \.self) { feature in
|
||
|
|
LockedFeatureRow(feature: feature)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.padding(.vertical, 8)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.padding()
|
||
|
|
}
|
||
|
|
|
||
|
|
private var unlockedFeatures: [FeatureGating.Feature] {
|
||
|
|
FeatureGating.Feature.allCases.filter {
|
||
|
|
featureGating.isAvailable($0)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private var lockedFeatures: [FeatureGating.Feature] {
|
||
|
|
FeatureGating.Feature.allCases.filter {
|
||
|
|
!featureGating.isAvailable($0)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### KeyActivationView
|
||
|
|
|
||
|
|
```swift
|
||
|
|
struct KeyActivationView: View {
|
||
|
|
@Environment(\.dismiss) private var dismiss
|
||
|
|
@StateObject private var keyManager = KeyManager.shared
|
||
|
|
@State private var keyInput = ""
|
||
|
|
@State private var isValidating = false
|
||
|
|
@State private var error: KeyError?
|
||
|
|
@State private var showSuccess = false
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
NavigationView {
|
||
|
|
VStack(spacing: 24) {
|
||
|
|
// Instructions
|
||
|
|
VStack(spacing: 8) {
|
||
|
|
Image(systemName: "key.fill")
|
||
|
|
.font(.system(size: 48))
|
||
|
|
.foregroundColor(.accentColor)
|
||
|
|
|
||
|
|
Text("Enter Your License Key")
|
||
|
|
.font(.title2)
|
||
|
|
.fontWeight(.semibold)
|
||
|
|
|
||
|
|
Text("Keys look like: VUUW-XXXX-XXXX-XXXX-L1")
|
||
|
|
.font(.caption)
|
||
|
|
.foregroundColor(.secondary)
|
||
|
|
}
|
||
|
|
.padding(.top)
|
||
|
|
|
||
|
|
// Key input field
|
||
|
|
VStack(alignment: .leading, spacing: 8) {
|
||
|
|
TextField("License Key", text: $keyInput)
|
||
|
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||
|
|
.autocapitalization(.allCharacters)
|
||
|
|
.disableAutocorrection(true)
|
||
|
|
.onChange(of: keyInput) { newValue in
|
||
|
|
// Auto-format with dashes
|
||
|
|
keyInput = formatKey(newValue)
|
||
|
|
}
|
||
|
|
|
||
|
|
if let error = error {
|
||
|
|
Label(error.localizedDescription, systemImage: "exclamationmark.circle.fill")
|
||
|
|
.font(.caption)
|
||
|
|
.foregroundColor(.red)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.padding(.horizontal)
|
||
|
|
|
||
|
|
// Activation button
|
||
|
|
Button(action: activateKey) {
|
||
|
|
if isValidating {
|
||
|
|
ProgressView()
|
||
|
|
.progressViewStyle(CircularProgressViewStyle())
|
||
|
|
.scaleEffect(0.8)
|
||
|
|
} else {
|
||
|
|
Text("Activate")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.buttonStyle(PrimaryButtonStyle())
|
||
|
|
.disabled(keyInput.count < 19 || isValidating)
|
||
|
|
.padding(.horizontal)
|
||
|
|
|
||
|
|
Spacer()
|
||
|
|
|
||
|
|
// Help text
|
||
|
|
VStack(spacing: 4) {
|
||
|
|
Text("Don't have a key?")
|
||
|
|
.font(.caption)
|
||
|
|
|
||
|
|
Button("Get one in the Purchase tab") {
|
||
|
|
dismiss()
|
||
|
|
}
|
||
|
|
.font(.caption)
|
||
|
|
}
|
||
|
|
.padding(.bottom)
|
||
|
|
}
|
||
|
|
.navigationTitle("Activate Key")
|
||
|
|
.navigationBarTitleDisplayMode(.inline)
|
||
|
|
.toolbar {
|
||
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
||
|
|
Button("Cancel") {
|
||
|
|
dismiss()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.alert("Success!", isPresented: $showSuccess) {
|
||
|
|
Button("OK") {
|
||
|
|
dismiss()
|
||
|
|
}
|
||
|
|
} message: {
|
||
|
|
Text("Your license has been activated successfully!")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func activateKey() {
|
||
|
|
isValidating = true
|
||
|
|
error = nil
|
||
|
|
|
||
|
|
Task {
|
||
|
|
let result = await keyManager.activate(key: keyInput)
|
||
|
|
|
||
|
|
await MainActor.run {
|
||
|
|
isValidating = false
|
||
|
|
|
||
|
|
switch result {
|
||
|
|
case .success:
|
||
|
|
showSuccess = true
|
||
|
|
HapticFeedback.success()
|
||
|
|
|
||
|
|
case .failure(let keyError):
|
||
|
|
error = keyError
|
||
|
|
HapticFeedback.error()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func formatKey(_ input: String) -> String {
|
||
|
|
// Remove all non-alphanumeric characters
|
||
|
|
let cleaned = input.uppercased().filter { $0.isLetter || $0.isNumber }
|
||
|
|
|
||
|
|
// Add dashes at appropriate positions
|
||
|
|
var formatted = ""
|
||
|
|
for (index, char) in cleaned.enumerated() {
|
||
|
|
if index > 0 && index % 4 == 0 && index < 16 {
|
||
|
|
formatted += "-"
|
||
|
|
}
|
||
|
|
formatted.append(char)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Limit length
|
||
|
|
return String(formatted.prefix(19))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### KeyBadge
|
||
|
|
|
||
|
|
```swift
|
||
|
|
struct KeyBadge: View {
|
||
|
|
let keysRequired: Int
|
||
|
|
let isCompact: Bool
|
||
|
|
|
||
|
|
init(keysRequired: Int, compact: Bool = false) {
|
||
|
|
self.keysRequired = keysRequired
|
||
|
|
self.isCompact = compact
|
||
|
|
}
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
if keysRequired > 0 {
|
||
|
|
HStack(spacing: isCompact ? 2 : 4) {
|
||
|
|
ForEach(0..<keysRequired, id: \.self) { _ in
|
||
|
|
Image(systemName: "key.fill")
|
||
|
|
.font(isCompact ? .caption2 : .caption)
|
||
|
|
.foregroundColor(.orange)
|
||
|
|
}
|
||
|
|
|
||
|
|
if !isCompact {
|
||
|
|
Text("Required")
|
||
|
|
.font(.caption2)
|
||
|
|
.foregroundColor(.secondary)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.padding(.horizontal, isCompact ? 4 : 8)
|
||
|
|
.padding(.vertical, isCompact ? 2 : 4)
|
||
|
|
.background(Color.orange.opacity(0.1))
|
||
|
|
.cornerRadius(isCompact ? 4 : 6)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### LockedFeatureRow
|
||
|
|
|
||
|
|
```swift
|
||
|
|
struct LockedFeatureRow: View {
|
||
|
|
let feature: FeatureGating.Feature
|
||
|
|
@ObservedObject private var featureGating = FeatureGating.shared
|
||
|
|
@State private var showDemo = false
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
HStack {
|
||
|
|
VStack(alignment: .leading, spacing: 4) {
|
||
|
|
Text(feature.displayName)
|
||
|
|
.font(.subheadline)
|
||
|
|
|
||
|
|
KeyBadge(keysRequired: featureGating.keysNeeded(for: feature))
|
||
|
|
}
|
||
|
|
|
||
|
|
Spacer()
|
||
|
|
|
||
|
|
if featureGating.canDemo(feature) {
|
||
|
|
Button("Demo") {
|
||
|
|
showDemo = true
|
||
|
|
}
|
||
|
|
.font(.caption)
|
||
|
|
.buttonStyle(.bordered)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.alert("Try \(feature.displayName)", isPresented: $showDemo) {
|
||
|
|
Button("Start 30s Demo") {
|
||
|
|
featureGating.temporarilyUnlock(feature, for: 30)
|
||
|
|
}
|
||
|
|
Button("Cancel", role: .cancel) {}
|
||
|
|
} message: {
|
||
|
|
Text("Experience this feature free for 30 seconds")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
### Custom Button Styles
|
||
|
|
|
||
|
|
```swift
|
||
|
|
struct PrimaryButtonStyle: ButtonStyle {
|
||
|
|
@Environment(\.isEnabled) private var isEnabled
|
||
|
|
|
||
|
|
func makeBody(configuration: Configuration) -> some View {
|
||
|
|
configuration.label
|
||
|
|
.frame(maxWidth: .infinity)
|
||
|
|
.padding()
|
||
|
|
.background(isEnabled ? Color.accentColor : Color.gray)
|
||
|
|
.foregroundColor(.white)
|
||
|
|
.cornerRadius(10)
|
||
|
|
.scaleEffect(configuration.isPressed ? 0.95 : 1)
|
||
|
|
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Design Principles
|
||
|
|
|
||
|
|
1. **Clear Visual Hierarchy**
|
||
|
|
- Current status prominently displayed
|
||
|
|
- Clear separation between unlocked/locked
|
||
|
|
- Visual indicators (keys, locks, checkmarks)
|
||
|
|
|
||
|
|
2. **Progressive Disclosure**
|
||
|
|
- Show relevant information based on license level
|
||
|
|
- Hide complex features until needed
|
||
|
|
- Clear upgrade paths
|
||
|
|
|
||
|
|
3. **Feedback and Validation**
|
||
|
|
- Real-time key formatting
|
||
|
|
- Clear error messages
|
||
|
|
- Success animations
|
||
|
|
- Haptic feedback
|
||
|
|
|
||
|
|
4. **Accessibility**
|
||
|
|
- VoiceOver labels
|
||
|
|
- Dynamic type support
|
||
|
|
- Color-blind friendly indicators
|
||
|
|
- Clear contrast ratios
|