Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
19bc01e
Store the server config in the database
JulianKahnert Oct 3, 2026
efbebef
Record automation runs with their trigger
JulianKahnert Oct 3, 2026
2b934f1
Link logged commands to the automation run that sent them
JulianKahnert Oct 3, 2026
9707fe7
Serve the history of a whole room in one request
JulianKahnert Oct 3, 2026
f14b6cf
Show automation runs, grouping and inline toggles in the Controller
JulianKahnert Oct 3, 2026
cdd6ef5
Add an Overview tab with status, running automations and an activity …
JulianKahnert Oct 3, 2026
de66162
Replace the History and Actions tabs with Rooms and a command log
JulianKahnert Oct 3, 2026
aeeab2d
Add timeline charts and a room timeline to the Controller
JulianKahnert Oct 3, 2026
b62531e
Show why MotionAtNight triggered in its automation details
JulianKahnert Oct 3, 2026
1ab68ce
Add daily ventilation, on-duration, CO2 and daily max/mean charts
JulianKahnert Oct 3, 2026
707b8db
Use native list rows and status labels in the Controller
JulianKahnert Oct 3, 2026
b032111
Keep the Controller independent of HAImplementations
JulianKahnert Oct 3, 2026
7bc6ecd
Let the Live Activity tests compile for iOS
JulianKahnert Oct 3, 2026
9beb7bc
Use the standard tab bar and system text styles
JulianKahnert Oct 3, 2026
a5812de
Polish the Controller layouts with native rows and chart legends
JulianKahnert Oct 3, 2026
0c76d00
Record automation runs without delaying the first command
JulianKahnert Oct 3, 2026
0b569bc
Push Controller details onto the navigation stack and load history in…
JulianKahnert Oct 3, 2026
8383f80
List only the devices an automation uses and link a run's trigger to …
JulianKahnert Oct 3, 2026
b4d5176
Cover the Controller reducers of the redesign with unit tests
JulianKahnert Oct 3, 2026
164323c
Open a running automation from the Overview tab
JulianKahnert Oct 3, 2026
6c4809d
Show the running automation row with a chevron instead of a tinted label
JulianKahnert Oct 3, 2026
603b8b1
Name the room next to a device's kind and keep the running row in lab…
JulianKahnert Oct 3, 2026
8d06e36
Merge branch 'develop' into feature/flowkit-controller-redesign
JulianKahnert Oct 3, 2026
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
103 changes: 48 additions & 55 deletions Sources/Controller/App/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ struct AppFeature: Sendable {

@ObservableState
struct State: Equatable, Sendable {
var selectedTab: Tab = .automations
var selectedTab: Tab = .overview
var overview = OverviewFeature.State()
var automations = AutomationsFeature.State()
var actions = ActionsFeature.State()
var history = HistoryFeature.State()
var rooms = RoomsFeature.State()
var settings = SettingsFeature.State()

var openWindowsCount: Int? {
Expand All @@ -38,25 +38,25 @@ struct AppFeature: Sendable {
// MARK: - Tab

enum Tab: Sendable, Equatable, CaseIterable {
case overview
case automations
case actions
case history
case rooms
case settings

var title: String {
switch self {
case .overview: return "Overview"
case .automations: return "Automations"
case .actions: return "Actions"
case .history: return "History"
case .rooms: return "Rooms"
case .settings: return "Settings"
}
}

var systemImage: String {
switch self {
case .overview: return "house"
case .automations: return "lamp.floor"
case .actions: return "list.bullet.clipboard"
case .history: return "chart.line.uptrend.xyaxis"
case .rooms: return "square.grid.2x2"
case .settings: return "gear"
}
}
Expand All @@ -66,9 +66,9 @@ struct AppFeature: Sendable {

enum Action: Sendable, BindableAction {
case selectedTabChanged(Tab)
case overview(OverviewFeature.Action)
case automations(AutomationsFeature.Action)
case actions(ActionsFeature.Action)
case history(HistoryFeature.Action)
case rooms(RoomsFeature.Action)
case settings(SettingsFeature.Action)

// Live Activities & Push Notifications
Expand All @@ -82,6 +82,7 @@ struct AppFeature: Sendable {

// Scene phase changes
case scenePhaseChanged(old: ScenePhase, new: ScenePhase)
case refreshAll

case binding(BindingAction<State>)
}
Expand All @@ -97,19 +98,19 @@ struct AppFeature: Sendable {
var body: some ReducerOf<Self> {
BindingReducer()

Scope(state: \.automations, action: \.automations) {
AutomationsFeature()
Scope(\.overview, action: \.overview) {
OverviewFeature()
}

Scope(state: \.actions, action: \.actions) {
ActionsFeature()
Scope(\.automations, action: \.automations) {
AutomationsFeature()
}

Scope(state: \.history, action: \.history) {
HistoryFeature()
Scope(\.rooms, action: \.rooms) {
RoomsFeature()
}

Scope(state: \.settings, action: \.settings) {
Scope(\.settings, action: \.settings) {
SettingsFeature()
}

Expand All @@ -119,13 +120,17 @@ struct AppFeature: Sendable {
state.selectedTab = tab
return .none

case .automations:
case let .overview(.delegate(.openAutomation(name))):
state.selectedTab = .automations
return .send(.automations(.openAutomation(name)))

case .overview:
return .none

case .actions:
case .automations:
return .none

case .history:
case .rooms:
return .none

case .settings:
Expand Down Expand Up @@ -201,11 +206,13 @@ struct AppFeature: Sendable {
return .none
}

// Refresh all tabs and start monitoring when app becomes active
return .send(.refreshAll)

case .refreshAll:
return .merge(
.send(.overview(.refresh)),
.send(.automations(.refresh)),
.send(.actions(.refresh)),
.send(.history(.refresh)),
.send(.rooms(.refresh)),
.send(.refreshWindowStates),
.send(.startMonitoringLiveActivities),
.send(.clearDeliveredNotifications)
Expand All @@ -224,42 +231,33 @@ struct AppView: View {
var body: some View {
TabView(selection: $store.selectedTab) {
Tab(
AppFeature.Tab.automations.title,
systemImage: AppFeature.Tab.automations.systemImage,
value: AppFeature.Tab.automations
AppFeature.Tab.overview.title,
systemImage: AppFeature.Tab.overview.systemImage,
value: AppFeature.Tab.overview
) {
AutomationsView(
store: store.scope(
state: \.automations,
action: \.automations
)
OverviewView(
store: store.scope(\.overview, action: \.overview),
openWindows: store.settings.windowContentState?.windowStates ?? []
)
}
.badge(store.openWindowsCount ?? 0)

Tab(
AppFeature.Tab.actions.title,
systemImage: AppFeature.Tab.actions.systemImage,
value: AppFeature.Tab.actions
AppFeature.Tab.automations.title,
systemImage: AppFeature.Tab.automations.systemImage,
value: AppFeature.Tab.automations
) {
ActionsView(
store: store.scope(
state: \.actions,
action: \.actions
)
AutomationsView(
store: store.scope(\.automations, action: \.automations)
)
}

Tab(
AppFeature.Tab.history.title,
systemImage: AppFeature.Tab.history.systemImage,
value: AppFeature.Tab.history
AppFeature.Tab.rooms.title,
systemImage: AppFeature.Tab.rooms.systemImage,
value: AppFeature.Tab.rooms
) {
HistoryView(
store: store.scope(
state: \.history,
action: \.history
)
)
RoomsView(store: store.scope(\.rooms, action: \.rooms))
}

Tab(
Expand All @@ -268,15 +266,10 @@ struct AppView: View {
value: AppFeature.Tab.settings
) {
SettingsView(
store: store.scope(
state: \.settings,
action: \.settings
)
store: store.scope(\.settings, action: \.settings)
)
}
.badge(store.openWindowsCount ?? 0)
}
.tabViewStyle(.sidebarAdaptable)
.onSceneChange { oldPhase, newPhase in
store.send(.scenePhaseChanged(old: oldPhase, new: newPhase))
}
Expand Down
36 changes: 36 additions & 0 deletions Sources/Controller/Charts/ChartPalette.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// ChartPalette.swift
// Controller
//

import HAModels
import SwiftUI

/// Chart colors follow the device type, never the series order, so a lamp is green in every chart.
enum ChartPalette {
static func color(for type: CharacteristicsType) -> Color {
switch type {
case .switcher, .brightness, .colorTemperature, .color:
return .green
case .motionSensor:
return .blue
case .contactSensor:
return .orange
case .lightSensor:
return .indigo
case .carbonDioxideSensorId:
return .teal
default:
return .gray
}
}

static let night = Color.gray.opacity(0.15)

/// Times for a day or less, weekdays beyond that; a week of "00:00" labels says nothing.
static func axisFormat(for domain: ClosedRange<Date>) -> Date.FormatStyle {
domain.upperBound.timeIntervalSince(domain.lowerBound) > 86_400 * 1.5
? .dateTime.weekday(.abbreviated)
: .dateTime.hour().minute()
}
}
48 changes: 48 additions & 0 deletions Sources/Controller/Charts/DailyBarChart.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// DailyBarChart.swift
// Controller
//

import Charts
import SwiftUI

/// One bar per day, with an optional point per day (e.g. the mean on top of the maximum).
struct DailyBarChart: View {
let bars: [(day: Date, value: Double)]
let color: Color
let unit: String
var points: [(day: Date, value: Double)] = []
var barLabel = "Total"
var pointLabel = "Mean"

var body: some View {
Chart {
ForEach(bars.indices, id: \.self) { index in
BarMark(x: .value("Day", bars[index].day, unit: .day), y: .value(unit, bars[index].value))
.foregroundStyle(by: .value("Series", barLabel))
}
ForEach(points.indices, id: \.self) { index in
PointMark(x: .value("Day", points[index].day, unit: .day), y: .value(unit, points[index].value))
.foregroundStyle(by: .value("Series", pointLabel))
}
}
.chartYAxisLabel(unit)
.chartForegroundStyleScale([barLabel: color, pointLabel: Color.primary])
.chartLegend(points.isEmpty ? .hidden : .visible)
.chartXAxis {
AxisMarks(values: .stride(by: .day)) { _ in
AxisValueLabel(format: .dateTime.weekday(.abbreviated), centered: true)
}
}
.frame(height: 140)
}
}

#Preview {
DailyBarChart(
bars: (0..<7).map { (Date().addingTimeInterval(Double($0 - 6) * 86_400), Double($0 * 10)) },
color: .orange,
unit: "min"
)
.padding()
}
79 changes: 79 additions & 0 deletions Sources/Controller/Charts/TimelineChart.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// TimelineChart.swift
// Controller
//

import Charts
import SwiftUI

/// State lanes (lamp on, motion, window open, …) over a shared time axis.
struct TimelineChart: View {
struct Lane: Identifiable {
let label: String
let color: Color
let intervals: [DateInterval]
/// Legend entry; lanes without one keep the chart legend hidden.
var kind: String?
var id: String { label }
}

let lanes: [Lane]
let domain: ClosedRange<Date>
var nights: [DateInterval] = []

var body: some View {
// Seconds-long motion pulses would vanish on a 24 h axis; keep each bar visible.
let minimumDuration = domain.upperBound.timeIntervalSince(domain.lowerBound) / 250
Chart {
ForEach(nights, id: \.start) { night in
RectangleMark(xStart: .value("Start", night.start), xEnd: .value("End", night.end))
.foregroundStyle(ChartPalette.night)
}
ForEach(lanes) { lane in
ForEach(lane.intervals, id: \.start) { interval in
RectangleMark(
xStart: .value("Start", interval.start),
xEnd: .value("End", max(interval.end, interval.start.addingTimeInterval(minimumDuration))),
y: .value("Lane", lane.label),
height: .fixed(12)
)
.foregroundStyle(by: .value("Kind", lane.kind ?? lane.label))
.clipShape(.rect(cornerRadius: 3))
}
}
}
.chartXScale(domain: domain)
.chartYScale(domain: lanes.map(\.label))
.chartForegroundStyleScale(domain: legendEntries.map(\.kind), range: legendEntries.map(\.color))
.chartLegend(lanes.contains { $0.kind != nil } ? .visible : .hidden)
.chartPlotStyle { $0.clipped() }
.chartXAxis {
// .aligned keeps the last time label inside the plot instead of clipping it.
AxisMarks(preset: .aligned) { _ in
AxisGridLine()
AxisValueLabel(format: ChartPalette.axisFormat(for: domain))
}
}
.frame(height: CGFloat(lanes.count) * 30 + (lanes.contains { $0.kind != nil } ? 54 : 30))
}

private var legendEntries: [(kind: String, color: Color)] {
var seen = Set<String>()
return lanes.compactMap { lane in
let kind = lane.kind ?? lane.label
return seen.insert(kind).inserted ? (kind, lane.color) : nil
}
}
}

#Preview {
let now = Date()
TimelineChart(
lanes: [
.init(label: "Worklight", color: .green, intervals: [DateInterval(start: now.addingTimeInterval(-7_200), end: now.addingTimeInterval(-3_600))]),
.init(label: "Window", color: .orange, intervals: [DateInterval(start: now.addingTimeInterval(-600), duration: 60)])
],
domain: now.addingTimeInterval(-86_400)...now
)
.padding()
}
Loading
Loading