Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 25 additions & 17 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
```swift
```
# Build artifacts
*.o
*.obj
*.a
*.lib
*.dylib
*.so
*.dll
*.exe
*.out

# Xcode
*.xcodeproj/
*.xcworkspace/
*.xcuserstate
project.xcworkspace/xcuserdata/
xcuserdata/

# Build
*.xcarchive/
*.app/
build/
DerivedData/

# Swift Package Manager
.swiftpm/
Package.resolved
# Swift
*.swiftmodule
*.swiftdoc
*.sib

# Dependencies
Packages/
.build/
.swift-version

# Temporary files
*.tmp
# Logs
*.log
*.swp

# Environment
.env
.env.local
*.env.*

# Dependencies
Pods/
Carthage/
.env.*
```
7 changes: 6 additions & 1 deletion Lumen/Features/Home/HomeDashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ struct HomeDashboardView: View {
.sheet(item: $lumenSheet) { sheet in
lumenSheetContent(sheet)
}
.animation(appState.sensoryProfile.shouldReduceMotion ? .default : .spring(response: 0.42, dampingFraction: 0.86), value: isStatusOverlayVisible)
.onAppear {
viewModel.load()
locationService.requestLocationPermission()
Expand Down Expand Up @@ -458,6 +459,9 @@ struct HomeDashboardView: View {

private func handleLumenSuggestion() {
lumenSheet = nil

// Record that a suggestion was shown today (for sensory profile limits)
appState.recordSuggestionShown()

// Execute the same scene the Action sheet displayed — single source of truth.
if let sceneName = suggestedSceneName, let scene = findScene(named: sceneName) {
Expand Down Expand Up @@ -516,7 +520,8 @@ struct HomeDashboardView: View {
hourOfDay: Calendar.current.component(.hour, from: Date()),
candidates: suggestionCandidates,
dailySuggestionLimit: appState.sensoryProfile.dailySuggestionLimit,
pausedSuggestions: appState.suggestionsPaused
pausedSuggestions: appState.suggestionsPaused,
hasShownSuggestionToday: appState.hasReachedDailySuggestionLimit
).topSuggestion()
}

Expand Down
13 changes: 8 additions & 5 deletions Lumen/Services/Intelligence/SuggestionEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ struct SuggestionEngine: Equatable {
/// Minimum confidence before Lumen will surface a suggestion at all. Below
/// this, the calm default is to stay quiet rather than nudge on a hunch.
static let surfaceThreshold: Double = 0.2

/// Tracks if a suggestion has already been shown today (for limit enforcement)
let hasShownSuggestionToday: Bool

// MARK: Public API

Expand All @@ -112,14 +115,14 @@ struct SuggestionEngine: Equatable {
func topSuggestion() -> SceneSuggestion? {
guard !pausedSuggestions else { return nil }

let best = rankedSuggestions().first
guard let best = best, best.confidence >= Self.surfaceThreshold else { return nil }

// Enforce daily suggestion limit from sensory profile
if let limit = dailySuggestionLimit, limit <= 0 {
// Enforce daily suggestion limit from sensory profile BEFORE scoring
if hasShownSuggestionToday {
return nil
}

let best = rankedSuggestions().first
guard let best = best, best.confidence >= Self.surfaceThreshold else { return nil }

return best
}

Expand Down
29 changes: 29 additions & 0 deletions Lumen/Services/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,35 @@ final class NotificationService {
}
}

/// Notify user before an automated transition happens (for sensory profile transition warnings)
func notifyUpcomingTransition(sceneName: String, minutesUntil: Int) {
let content = UNMutableNotificationContent()
content.title = "Lumen — Upcoming Change"
content.subtitle = "In \(minutesUntil) minutes"
content.body = "'\(sceneName)' will activate soon. You can adjust or postpone in the app."
content.sound = .default

// Add custom data for deep linking
content.userInfo = [
"sceneName": sceneName,
"eventType": "upcoming_transition",
"minutesUntil": minutesUntil,
"timestamp": Date().timeIntervalSince1970
]

// Schedule notification to fire at the warning time
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(minutesUntil * 60), repeats: false)
let request = UNNotificationRequest(identifier: "transition_\(UUID().uuidString)", content: content, trigger: trigger)

UNUserNotificationCenter.current().add(request) { error in
if let error = error {
print("Failed to schedule transition warning: \(error)")
} else {
print("Transition warning scheduled: \(sceneName) in \(minutesUntil) min")
}
}
}

// MARK: - Clear Notifications

func clearBadge() {
Expand Down
56 changes: 56 additions & 0 deletions Lumen/Views/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import Observation
@Observable
final class AppState {
@ObservationIgnored private static let sensoryProfileDefaultsKey = "lumen.sensoryProfile.v1"
@ObservationIgnored private static let suggestionCountKey = "lumen.suggestionCount.v1"
@ObservationIgnored private static let lastSuggestionDateKey = "lumen.lastSuggestionDate.v1"
@ObservationIgnored private let userDefaults: UserDefaults

var selectedTab: Tab = .home
Expand All @@ -20,10 +22,64 @@ final class AppState {
didSet { saveSensoryProfile() }
}
var suggestionsPaused: Bool = false

/// Tracks how many suggestions have been shown today (respects sensory profile limits)
var todaysSuggestionCount: Int {
didSet { saveSuggestionTracking() }
}

/// Last date a suggestion was shown (for daily reset)
var lastSuggestionDate: Date? {
didSet { saveSuggestionTracking() }
}

init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
self.sensoryProfile = Self.loadSensoryProfile(from: userDefaults)
self.todaysSuggestionCount = Self.loadTodaysSuggestionCount(from: userDefaults)
self.lastSuggestionDate = Self.loadLastSuggestionDate(from: userDefaults)
Self.resetSuggestionCountIfNeeded(userDefaults: userDefaults)
}

/// Check if daily limit has been reached based on sensory profile
var hasReachedDailySuggestionLimit: Bool {
guard let limit = sensoryProfile.dailySuggestionLimit else { return false }
return todaysSuggestionCount >= limit
}

/// Increment suggestion count and reset if new day
func recordSuggestionShown() {
Self.resetSuggestionCountIfNeeded(userDefaults: userDefaults)
todaysSuggestionCount += 1
lastSuggestionDate = Date()
}

/// Reset suggestion count for new day
private static func resetSuggestionCountIfNeeded(userDefaults: UserDefaults) {
guard let lastDate = loadLastSuggestionDate(from: userDefaults) else { return }

let calendar = Calendar.current
if !calendar.isDateInToday(lastDate) {
userDefaults.set(0, forKey: suggestionCountKey)
userDefaults.set(Date(), forKey: lastSuggestionDateKey)
}
}

private static func loadTodaysSuggestionCount(from userDefaults: UserDefaults) -> Int {
return userDefaults.integer(forKey: suggestionCountKey)
}

private static func loadLastSuggestionDate(from userDefaults: UserDefaults) -> Date? {
guard let data = userDefaults.data(forKey: lastSuggestionDateKey) else { return nil }
return try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? Date
}

private func saveSuggestionTracking() {
userDefaults.set(todaysSuggestionCount, forKey: Self.suggestionCountKey)
if let date = lastSuggestionDate {
let data = try? NSKeyedArchiver.archivedData(withRootObject: date, requiringSecureCoding: false)
userDefaults.set(data, forKey: Self.lastSuggestionDateKey)
}
}

enum Tab: String, CaseIterable, Hashable {
Expand Down
1 change: 1 addition & 0 deletions Lumen/Views/LumenApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ struct LumenApp: App {
.environment(locationService)
.environment(remoteService)
.environment(localDeviceService)
.environment(\.accessibilityReduceMotion, appState.sensoryProfile.shouldReduceMotion)
}
.modelContainer(container)
}
Expand Down
Loading