308 lines
10 KiB
Markdown
308 lines
10 KiB
Markdown
|
|
# FeatureGating Component
|
||
|
|
|
||
|
|
The FeatureGating component determines which features are available based on the user's license level and manages temporary feature unlocks for demo purposes.
|
||
|
|
|
||
|
|
## Pseudocode
|
||
|
|
|
||
|
|
```swift
|
||
|
|
class FeatureGating: ObservableObject {
|
||
|
|
// Current state
|
||
|
|
@Published private(set) var availableFeatures: Set<Feature> = []
|
||
|
|
private var currentLevel: LicenseLevel = .free
|
||
|
|
private var temporaryUnlocks: [Feature: Date] = [:]
|
||
|
|
|
||
|
|
// Timer for demo expirations
|
||
|
|
private var demoTimer: Timer?
|
||
|
|
|
||
|
|
// MARK: - Feature Definitions
|
||
|
|
|
||
|
|
enum Feature: String, CaseIterable {
|
||
|
|
// Free features (always available)
|
||
|
|
case basicVisualization = "basic_visualization"
|
||
|
|
case singleMonitor = "single_monitor"
|
||
|
|
case standardHaptics = "standard_haptics"
|
||
|
|
|
||
|
|
// Level 1 features (1 key required)
|
||
|
|
case monitorReordering = "monitor_reordering"
|
||
|
|
case breathingMonitor = "breathing_monitor"
|
||
|
|
case articulationMonitor = "articulation_monitor"
|
||
|
|
case toneMonitor = "tone_monitor"
|
||
|
|
case rhythmMonitor = "rhythm_monitor"
|
||
|
|
case resonanceMonitor = "resonance_monitor"
|
||
|
|
case voiceQualityMonitor = "voice_quality_monitor"
|
||
|
|
case brightnessMonitor = "brightness_monitor"
|
||
|
|
case vocalFryMonitor = "vocal_fry_monitor"
|
||
|
|
case tremorMonitor = "tremor_monitor"
|
||
|
|
case strainMonitor = "strain_monitor"
|
||
|
|
case diplophoniaMonitor = "diplophonia_monitor"
|
||
|
|
case hideMonetizationUI = "hide_monetization_ui"
|
||
|
|
|
||
|
|
// Level 2 features (2 keys required)
|
||
|
|
case circleTrailVisualization = "circle_trail_tvr_monitors"
|
||
|
|
case multiMonitorView = "multi_monitor_view"
|
||
|
|
case advancedCustomization = "advanced_customization"
|
||
|
|
|
||
|
|
var requiredLevel: LicenseLevel {
|
||
|
|
switch self {
|
||
|
|
case .basicVisualization, .singleMonitor, .standardHaptics:
|
||
|
|
return .free
|
||
|
|
|
||
|
|
case .monitorReordering, .breathingMonitor, .articulationMonitor,
|
||
|
|
.toneMonitor, .rhythmMonitor, .resonanceMonitor,
|
||
|
|
.voiceQualityMonitor, .brightnessMonitor, .vocalFryMonitor,
|
||
|
|
.tremorMonitor, .strainMonitor, .diplophoniaMonitor,
|
||
|
|
.hideMonetizationUI:
|
||
|
|
return .level1
|
||
|
|
|
||
|
|
case .circleTrailVisualization, .multiMonitorView, .advancedCustomization:
|
||
|
|
return .level2
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var displayName: String {
|
||
|
|
switch self {
|
||
|
|
case .basicVisualization: return "Basic Visualization"
|
||
|
|
case .singleMonitor: return "Single Voice Monitor"
|
||
|
|
case .standardHaptics: return "Standard Haptic Feedback"
|
||
|
|
case .monitorReordering: return "Reorder Monitors"
|
||
|
|
case .breathingMonitor: return "Breathing Monitor"
|
||
|
|
case .articulationMonitor: return "Articulation Monitor"
|
||
|
|
case .toneMonitor: return "Tone Monitor"
|
||
|
|
case .rhythmMonitor: return "Rhythm Monitor"
|
||
|
|
case .resonanceMonitor: return "Resonance Monitor"
|
||
|
|
case .voiceQualityMonitor: return "Voice Quality Monitor"
|
||
|
|
case .brightnessMonitor: return "Brightness Monitor"
|
||
|
|
case .vocalFryMonitor: return "Vocal Fry Monitor"
|
||
|
|
case .tremorMonitor: return "Tremor Monitor"
|
||
|
|
case .strainMonitor: return "Strain Monitor"
|
||
|
|
case .diplophoniaMonitor: return "Diplophonia Monitor"
|
||
|
|
case .hideMonetizationUI: return "Hide Monetization UI"
|
||
|
|
case .circleTrailVisualization: return "Circle Trail + Monitors"
|
||
|
|
case .multiMonitorView: return "Multi-Monitor View"
|
||
|
|
case .advancedCustomization: return "Advanced Customization"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - License Level Updates
|
||
|
|
|
||
|
|
func updateAvailableFeatures(for level: LicenseLevel) {
|
||
|
|
currentLevel = level
|
||
|
|
|
||
|
|
// Clear previous features
|
||
|
|
availableFeatures.removeAll()
|
||
|
|
|
||
|
|
// Add all features up to current level
|
||
|
|
for feature in Feature.allCases {
|
||
|
|
if feature.requiredLevel.rawValue <= level.rawValue {
|
||
|
|
availableFeatures.insert(feature)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Re-apply any active demo unlocks
|
||
|
|
applyTemporaryUnlocks()
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Feature Availability Checks
|
||
|
|
|
||
|
|
func isAvailable(_ feature: Feature) -> Bool {
|
||
|
|
// Check if permanently unlocked
|
||
|
|
if availableFeatures.contains(feature) {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check if temporarily unlocked
|
||
|
|
if let expiryDate = temporaryUnlocks[feature] {
|
||
|
|
return Date() < expiryDate
|
||
|
|
}
|
||
|
|
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func keysNeeded(for feature: Feature) -> Int {
|
||
|
|
let currentKeys = currentLevel.rawValue
|
||
|
|
let requiredKeys = feature.requiredLevel.rawValue
|
||
|
|
|
||
|
|
return max(0, requiredKeys - currentKeys)
|
||
|
|
}
|
||
|
|
|
||
|
|
func canDemo(_ feature: Feature) -> Bool {
|
||
|
|
// Can only demo features above current level
|
||
|
|
return feature.requiredLevel.rawValue > currentLevel.rawValue
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Demo Mode
|
||
|
|
|
||
|
|
func temporarilyUnlock(_ feature: Feature, for duration: TimeInterval) {
|
||
|
|
guard canDemo(feature) else { return }
|
||
|
|
|
||
|
|
let expiryDate = Date().addingTimeInterval(duration)
|
||
|
|
temporaryUnlocks[feature] = expiryDate
|
||
|
|
|
||
|
|
// Add to available features temporarily
|
||
|
|
availableFeatures.insert(feature)
|
||
|
|
|
||
|
|
// Start or update timer
|
||
|
|
startDemoTimer()
|
||
|
|
|
||
|
|
// Notify observers
|
||
|
|
objectWillChange.send()
|
||
|
|
}
|
||
|
|
|
||
|
|
private func startDemoTimer() {
|
||
|
|
demoTimer?.invalidate()
|
||
|
|
|
||
|
|
demoTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
|
||
|
|
self.checkDemoExpirations()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func checkDemoExpirations() {
|
||
|
|
let now = Date()
|
||
|
|
var hasExpired = false
|
||
|
|
|
||
|
|
for (feature, expiryDate) in temporaryUnlocks {
|
||
|
|
if now >= expiryDate {
|
||
|
|
// Remove expired demo
|
||
|
|
temporaryUnlocks.removeValue(forKey: feature)
|
||
|
|
|
||
|
|
// Remove from available if not permanently unlocked
|
||
|
|
if feature.requiredLevel.rawValue > currentLevel.rawValue {
|
||
|
|
availableFeatures.remove(feature)
|
||
|
|
}
|
||
|
|
|
||
|
|
hasExpired = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if hasExpired {
|
||
|
|
objectWillChange.send()
|
||
|
|
}
|
||
|
|
|
||
|
|
// Stop timer if no more demos
|
||
|
|
if temporaryUnlocks.isEmpty {
|
||
|
|
demoTimer?.invalidate()
|
||
|
|
demoTimer = nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private func applyTemporaryUnlocks() {
|
||
|
|
let now = Date()
|
||
|
|
|
||
|
|
for (feature, expiryDate) in temporaryUnlocks {
|
||
|
|
if now < expiryDate {
|
||
|
|
availableFeatures.insert(feature)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - UI Helpers
|
||
|
|
|
||
|
|
func badgeText(for feature: Feature) -> String? {
|
||
|
|
if isAvailable(feature) {
|
||
|
|
return nil // No badge needed
|
||
|
|
}
|
||
|
|
|
||
|
|
let keysNeeded = keysNeeded(for: feature)
|
||
|
|
return String(repeating: "🔑", count: keysNeeded)
|
||
|
|
}
|
||
|
|
|
||
|
|
func shouldShowMonetizationUI() -> Bool {
|
||
|
|
// Hide if user has Level 1 or higher
|
||
|
|
return !isAvailable(.hideMonetizationUI)
|
||
|
|
}
|
||
|
|
|
||
|
|
func getDemoTimeRemaining(for feature: Feature) -> TimeInterval? {
|
||
|
|
guard let expiryDate = temporaryUnlocks[feature] else {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
let remaining = expiryDate.timeIntervalSince(Date())
|
||
|
|
return remaining > 0 ? remaining : nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - SwiftUI View Modifier
|
||
|
|
|
||
|
|
struct FeatureGated: ViewModifier {
|
||
|
|
let feature: FeatureGating.Feature
|
||
|
|
@ObservedObject var gating = FeatureGating.shared
|
||
|
|
@State private var showUpgradePrompt = false
|
||
|
|
|
||
|
|
func body(content: Content) -> some View {
|
||
|
|
if gating.isAvailable(feature) {
|
||
|
|
content
|
||
|
|
} else {
|
||
|
|
ZStack {
|
||
|
|
content
|
||
|
|
.blur(radius: 3)
|
||
|
|
.disabled(true)
|
||
|
|
|
||
|
|
VStack(spacing: 12) {
|
||
|
|
Image(systemName: "lock.fill")
|
||
|
|
.font(.largeTitle)
|
||
|
|
.foregroundColor(.secondary)
|
||
|
|
|
||
|
|
Text(gating.badgeText(for: feature) ?? "")
|
||
|
|
.font(.title2)
|
||
|
|
|
||
|
|
Text(feature.displayName)
|
||
|
|
.font(.headline)
|
||
|
|
|
||
|
|
Text("Requires \(feature.requiredLevel.displayName)")
|
||
|
|
.font(.caption)
|
||
|
|
.foregroundColor(.secondary)
|
||
|
|
|
||
|
|
HStack(spacing: 16) {
|
||
|
|
if gating.canDemo(feature) {
|
||
|
|
Button("Try Demo") {
|
||
|
|
gating.temporarilyUnlock(feature, for: 30)
|
||
|
|
}
|
||
|
|
.buttonStyle(.bordered)
|
||
|
|
}
|
||
|
|
|
||
|
|
Button("Unlock") {
|
||
|
|
showUpgradePrompt = true
|
||
|
|
}
|
||
|
|
.buttonStyle(.borderedProminent)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.padding()
|
||
|
|
.background(.regularMaterial)
|
||
|
|
.cornerRadius(12)
|
||
|
|
}
|
||
|
|
.sheet(isPresented: $showUpgradePrompt) {
|
||
|
|
KeysForAllUpgradeView(targetFeature: feature)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
extension View {
|
||
|
|
func gated(by feature: FeatureGating.Feature) -> some View {
|
||
|
|
modifier(FeatureGated(feature: feature))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
## Feature Management
|
||
|
|
|
||
|
|
1. **Feature Definition**
|
||
|
|
- Centralized feature list
|
||
|
|
- Clear level requirements
|
||
|
|
- Display names for UI
|
||
|
|
|
||
|
|
2. **Availability Logic**
|
||
|
|
- Permanent unlocks via license
|
||
|
|
- Temporary unlocks for demos
|
||
|
|
- Dynamic badge generation
|
||
|
|
|
||
|
|
3. **Demo System**
|
||
|
|
- 30-second demos for locked features
|
||
|
|
- Automatic expiration
|
||
|
|
- Timer-based cleanup
|
||
|
|
|
||
|
|
4. **UI Integration**
|
||
|
|
- SwiftUI view modifier
|
||
|
|
- Automatic lock overlays
|
||
|
|
- Upgrade prompts
|