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
32 changes: 30 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,30 @@
# Documentation outputs
outputs/
```swift
# Xcode
*.xcodeproj/
*.xcworkspace/
*.xcuserstate
project.xcworkspace/xcuserdata/
xcuserdata/

# Build
build/
DerivedData/

# Swift Package Manager
.swiftpm/
Package.resolved

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

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

# Dependencies
Pods/
Carthage/
```
68 changes: 54 additions & 14 deletions Lumen/Features/Home/HomeDashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ struct HomeDashboardView: View {
@Query private var executions: [ExecutionEvent]
@Environment(\.horizontalSizeClass) private var sizeClass
@Environment(LocationService.self) private var locationService
@Environment(AppState.self) private var appState
@State private var isRenamingHome = false
@State private var renameText = ""
@State private var lumenSheet: LumenDashboardSheet?
Expand Down Expand Up @@ -345,20 +346,56 @@ struct HomeDashboardView: View {

private var lumenNoticedSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("LUMEN NOTICED")
.font(.system(size: 10, weight: .semibold))
.tracking(2.5)
.foregroundStyle(Color.white.opacity(0.35))
HStack {
Text("LUMEN NOTICED")
.font(.system(size: 10, weight: .semibold))
.tracking(2.5)
.foregroundStyle(Color.white.opacity(0.35))
Spacer()
if appState.suggestionsPaused {
Button(action: { appState.suggestionsPaused = false }) {
Label("Resume", systemImage: "play.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Color.lumenAccent)
}
} else if suggestion != nil {
Button(action: { appState.suggestionsPaused = true }) {
Label("Pause", systemImage: "pause.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Color.white.opacity(0.45))
}
}
}

VStack(spacing: 8) {
LumenNoticedCard(
message: noticePresentation.message,
suggestion: noticePresentation.suggestion,
detail: noticePresentation.detail,
icon: noticePresentation.iconName,
isActionable: noticePresentation.isActionable,
action: { lumenSheet = .reasoning }
)
if appState.suggestionsPaused {
LumenNoticedCard(
message: "Suggestions are paused.",
suggestion: "Tap resume to receive suggestions again.",
detail: "You can unpause anytime.",
icon: "pause.fill",
isActionable: true,
action: { appState.suggestionsPaused = false }
)
} else if let suggestion = suggestion {
LumenNoticedCard(
message: noticePresentation.message,
suggestion: noticePresentation.suggestion,
detail: noticePresentation.detail,
icon: noticePresentation.iconName,
isActionable: noticePresentation.isActionable,
action: { lumenSheet = .reasoning }
)
} else {
LumenNoticedCard(
message: "Lumen is monitoring your home.",
suggestion: "No suggestions right now.",
detail: "Everything looks calm.",
icon: "sparkles",
isActionable: false,
action: {}
)
}
}
}
}
Expand Down Expand Up @@ -477,7 +514,9 @@ struct HomeDashboardView: View {
presence: locationService.isAtHome ? .home : .away,
reachableDevices: viewModel.reachableDeviceCount,
hourOfDay: Calendar.current.component(.hour, from: Date()),
candidates: suggestionCandidates
candidates: suggestionCandidates,
dailySuggestionLimit: appState.sensoryProfile.dailySuggestionLimit,
pausedSuggestions: appState.suggestionsPaused
).topSuggestion()
}

Expand Down Expand Up @@ -523,7 +562,8 @@ struct HomeDashboardView: View {
suggestedSceneName: suggestedSceneName,
expectedSceneName: expectedSceneName,
confidence: suggestion?.confidence,
habitRuns: suggestion?.habitRuns
habitRuns: suggestion?.habitRuns,
factors: suggestion?.factors ?? []
).reasoning
}

Expand Down
63 changes: 53 additions & 10 deletions Lumen/Features/Home/LumenReasoningView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,53 @@ struct LumenReasoningView: View {
}

private var signalList: some View {
VStack(alignment: .leading, spacing: 10) {
Text("SIGNALS")
.font(.system(size: 10, weight: .semibold))
.tracking(2)
.foregroundStyle(Color.white.opacity(0.35))

ForEach(reasoning.signals) { signal in
signalRow(signal)
VStack(alignment: .leading, spacing: 16) {
if !reasoning.factors.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("WHY THIS SCENE")
.font(.system(size: 10, weight: .semibold))
.tracking(2)
.foregroundStyle(Color.white.opacity(0.35))

ForEach(reasoning.factors) { factor in
factorRow(factor)
}
}
}

VStack(alignment: .leading, spacing: 10) {
Text("SIGNALS")
.font(.system(size: 10, weight: .semibold))
.tracking(2)
.foregroundStyle(Color.white.opacity(0.35))

ForEach(reasoning.signals) { signal in
signalRow(signal)
}
}
}
}

private func factorRow(_ factor: SuggestionFactor) -> some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(Color.lumenAccent)
.frame(width: 6, height: 6)
.padding(.top, 6)

VStack(alignment: .leading, spacing: 2) {
Text(factor.label)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Color.white.opacity(0.85))
Text(factor.detail)
.font(.system(size: 12))
.foregroundStyle(Color.white.opacity(0.55))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 14))
}

private func signalRow(_ signal: ReasoningSignal) -> some View {
Expand Down Expand Up @@ -153,6 +190,7 @@ struct LumenReasoning: Equatable {
let headline: String
let signals: [ReasoningSignal]
let suggestionLabel: String?
let factors: [SuggestionFactor]
}

struct ReasoningSignal: Equatable, Identifiable {
Expand Down Expand Up @@ -194,6 +232,8 @@ struct ReasoningCalculator: Equatable {
// the raw ambient state.
let confidence: Double?
let habitRuns: Int?
/// Explainable factors from the SuggestionEngine scoring
let factors: [SuggestionFactor]

init(
timeOfDay: TimeOfDay,
Expand All @@ -203,7 +243,8 @@ struct ReasoningCalculator: Equatable {
suggestedSceneName: String?,
expectedSceneName: String? = nil,
confidence: Double? = nil,
habitRuns: Int? = nil
habitRuns: Int? = nil,
factors: [SuggestionFactor] = []
) {
self.timeOfDay = timeOfDay
self.isAtHome = isAtHome
Expand All @@ -213,13 +254,15 @@ struct ReasoningCalculator: Equatable {
self.expectedSceneName = expectedSceneName
self.confidence = confidence
self.habitRuns = habitRuns
self.factors = factors
}

var reasoning: LumenReasoning {
LumenReasoning(
headline: headline,
signals: signals,
suggestionLabel: suggestedSceneName.map { "Apply \($0)" }
suggestionLabel: suggestedSceneName.map { "Apply \($0)" },
factors: factors
)
}

Expand Down
21 changes: 17 additions & 4 deletions Lumen/Services/Intelligence/SuggestionEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ struct SuggestionEngine: Equatable {
let reachableDevices: Int
let hourOfDay: Int
let candidates: [SuggestionCandidate]
/// Sensory profile constraints from user preferences
let dailySuggestionLimit: Int?
let pausedSuggestions: Bool

/// 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.
Expand All @@ -93,7 +96,9 @@ struct SuggestionEngine: Equatable {
/// All candidates scored and sorted, highest confidence first. Deterministic:
/// ties break on run volume, then scene name, so the UI never flickers.
func rankedSuggestions() -> [SceneSuggestion] {
candidates
guard !pausedSuggestions else { return [] }

return candidates
.map(score(_:))
.sorted { lhs, rhs in
if lhs.confidence != rhs.confidence { return lhs.confidence > rhs.confidence }
Expand All @@ -103,10 +108,18 @@ struct SuggestionEngine: Equatable {
}

/// The single scene worth suggesting now, or `nil` if nothing clears the
/// calm threshold.
/// calm threshold, or if daily limit has been reached.
func topSuggestion() -> SceneSuggestion? {
guard let best = rankedSuggestions().first,
best.confidence >= Self.surfaceThreshold else { return nil }
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 {
return nil
}

return best
}

Expand Down
1 change: 1 addition & 0 deletions Lumen/Views/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ final class AppState {
var sensoryProfile: SensoryProfile {
didSet { saveSensoryProfile() }
}
var suggestionsPaused: Bool = false

init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
Expand Down
Loading
Loading