Native Swift 6 SwiftUI PowerSync iOS app (HIG / Liquid Glass) - #40
Native Swift 6 SwiftUI PowerSync iOS app (HIG / Liquid Glass)#40Artic0din wants to merge 6 commits into
Conversation
Native iOS 26+ front end using only Apple frameworks (SwiftUI, Charts, WidgetKit, ActivityKit). HIG-aligned TabView/List/Form screens for dashboard, controls, automations, settings, optimization, prices, EV, and battery health, plus Demo Mode and Home Assistant URLSession client. Co-authored-by: Artic0din <Artic0din@users.noreply.github.com>
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_74c4e884-8d15-4578-9b4e-77e0b8d03c1c) |
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_9820839c-6d16-4bfb-be96-72e303653877) |
There was a problem hiding this comment.
Pull request overview
This PR introduces a new ios/ subtree containing a native Swift 6 / SwiftUI PowerSync companion app, including a WidgetKit/ActivityKit extension, and the Xcode project scaffolding needed to build it on macOS with Xcode 26+.
Changes:
- Added a SwiftUI iOS app shell with demo-mode data, Home Assistant REST connectivity, and primary screens (Dashboard/Controls/Optimization/Prices/EV/Settings/Onboarding).
- Added a WidgetKit bundle plus a Live Activity implementation that reads shared app-group snapshots.
- Added an Xcode project generator script and checked in
PowerSync.xcodeproj, plus Xcode-related.gitignoreentries.
Reviewed changes
Copilot reviewed 40 out of 41 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| ios/scripts/generate_xcodeproj.py | Generates PowerSync.xcodeproj/project.pbxproj for the app + widgets targets. |
| ios/README.md | Build/run instructions for the iOS app and project regeneration steps. |
| ios/PowerSyncWidgets/StatusLockScreenWidget.swift | Lock screen/status widget using shared snapshot data. |
| ios/PowerSyncWidgets/SharedSnapshot.swift | Widget-side snapshot model + app-group read helper. |
| ios/PowerSyncWidgets/PowerSyncWidgetsBundle.swift | Widget bundle entry point registering widgets + live activity. |
| ios/PowerSyncWidgets/PowerSyncWidgets.entitlements | App Group entitlement for sharing snapshots. |
| ios/PowerSyncWidgets/OptimizationLiveActivity.swift | Live Activity UI for optimization status/savings. |
| ios/PowerSyncWidgets/Info.plist | Widget extension Info.plist (WidgetKit extension point + Live Activities flag). |
| ios/PowerSyncWidgets/ImportPriceWidget.swift | Widget showing import price and solar generation. |
| ios/PowerSyncWidgets/BatteryGaugeWidget.swift | Widget showing battery SOC gauge. |
| ios/PowerSync/Views/Settings/SettingsView.swift | Settings landing page (battery system, services, connection, about). |
| ios/PowerSync/Views/Settings/ConnectionSettingsView.swift | HA URL/token entry UI and connect action. |
| ios/PowerSync/Views/Settings/BatteryHealthView.swift | Battery health UI including charts and rescan action. |
| ios/PowerSync/Views/RootView.swift | Root routing between onboarding and main tabs. |
| ios/PowerSync/Views/Prices/PricesView.swift | Prices & tariff UI including forecast chart and TOU schedule. |
| ios/PowerSync/Views/Optimization/OptimizationView.swift | Optimization UI (mode picker, status/summary cards, plan chart). |
| ios/PowerSync/Views/Onboarding/OnboardingView.swift | Onboarding screen with demo mode and connect entry points. |
| ios/PowerSync/Views/MainTabView.swift | TabView-based primary navigation across app sections. |
| ios/PowerSync/Views/EV/EVChargingView.swift | EV charging UI and mode toggles. |
| ios/PowerSync/Views/Dashboard/OptimizationGlanceCard.swift | Dashboard card linking to optimization details. |
| ios/PowerSync/Views/Dashboard/EnergyFlowSection.swift | Dashboard “live energy flow” grid UI. |
| ios/PowerSync/Views/Dashboard/DashboardView.swift | Dashboard composition and refresh actions. |
| ios/PowerSync/Views/Controls/ControlsView.swift | Controls UI for force modes, reserve slider, grid settings. |
| ios/PowerSync/Views/Automations/AutomationsView.swift | Automations list with enable/disable toggles. |
| ios/PowerSync/Services/SharedSnapshotWriter.swift | App-side snapshot encoding to app group + widget/live activity refresh triggers. |
| ios/PowerSync/Services/LiveActivityManager.swift | Starts/updates ActivityKit live activities from app state. |
| ios/PowerSync/Services/KeychainStore.swift | Keychain persistence for HA URL/token. |
| ios/PowerSync/Services/HomeAssistantClient.swift | URLSession-based Home Assistant REST client actor. |
| ios/PowerSync/Services/Formatters.swift | Shared formatting helpers for currency/units/dates. |
| ios/PowerSync/Services/DemoDataFactory.swift | Demo-mode data generation for snapshots/optimization/prices/etc. |
| ios/PowerSync/Services/AppModel.swift | Main observable app model: state, demo mode, HA sync, and service calls. |
| ios/PowerSync/Resources/Assets.xcassets/Contents.json | Asset catalog root metadata. |
| ios/PowerSync/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json | Placeholder AppIcon asset slot. |
| ios/PowerSync/Resources/Assets.xcassets/AccentColor.colorset/Contents.json | AccentColor definition. |
| ios/PowerSync/PowerSyncApp.swift | SwiftUI app entry point and environment injection. |
| ios/PowerSync/PowerSync.entitlements | App Group entitlement for sharing snapshots. |
| ios/PowerSync/Models/EnergyModels.swift | Domain model types for snapshot/optimization/prices/EV/etc. |
| ios/PowerSync/LiveActivity/OptimizationActivityAttributes.swift | App-target ActivityAttributes definition for live activity. |
| ios/PowerSync.xcodeproj/xcshareddata/xcschemes/PowerSync.xcscheme | Shared Xcode scheme configuration. |
| ios/PowerSync.xcodeproj/project.pbxproj | Checked-in Xcode project configuration for app + widgets targets. |
| .gitignore | Adds Xcode-derived/xcuserdata ignore rules. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| text = "\n".join(lines) + "\n" | ||
| # Patch: Assets.xcassets fileRef should use path relative to Resources | ||
| # We'll move Assets into Resources by adjusting app_group generation — already complex. | ||
| # Simplest fix: change assets path to Resources/Assets.xcassets and put it in app group children with name. | ||
|
|
||
| # Rewrite assets file reference to include Resources/ path from PowerSync root | ||
| text = text.replace( | ||
| f"{assets_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = \"<group>\"; }};", | ||
| f"{assets_ref} /* Assets.xcassets */ = {{isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Resources/Assets.xcassets; sourceTree = \"<group>\"; }};", | ||
| ) | ||
|
|
| } | ||
|
|
||
| Section("About") { | ||
| LabeledContent("PowerSync", value: "2.12") |
| HStack(spacing: 10) { | ||
| ForEach(Array(model.touSlots.enumerated()), id: \.element.id) { index, slot in | ||
| VStack(spacing: 6) { | ||
| Text(slot.time.formatted(Formatters.time)) | ||
| .font(.caption.weight(.semibold)) | ||
| Text("B \(slot.buyCents.formatted(.number.precision(.fractionLength(0))))¢") | ||
| .font(.caption2) | ||
| .foregroundStyle(.red) | ||
| Text("S \(slot.sellCents.formatted(.number.precision(.fractionLength(0))))¢") | ||
| .font(.caption2) | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| .padding(12) | ||
| .glassEffect(index == 2 ? .regular : .identity, in: .rect(cornerRadius: 16)) | ||
| .overlay { | ||
| if index == 2 { | ||
| RoundedRectangle(cornerRadius: 16, style: .continuous) | ||
| .strokeBorder(.mint, lineWidth: 2) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
| private func makeRequest(path: String) throws -> URLRequest { | ||
| guard let baseURL, let token, !token.isEmpty else { | ||
| throw HomeAssistantClientError.invalidURL | ||
| } | ||
| guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else { | ||
| throw HomeAssistantClientError.invalidURL | ||
| } |
There was a problem hiding this comment.
💡 Codex Review
https://github.com/Artic0din/PowerSync/blob/df52a194e509c04464506ea1162dc73e157438aa/ios/PowerSync/Views/Controls/ControlsView.swift#L232-L233
Debounce backup reserve writes while dragging
In live mode, SwiftUI fires this onChange for every intermediate slider value, so dragging from 20% to 80% can enqueue dozens of power_sync.set_backup_reserve calls and potentially hit the battery provider APIs repeatedly. Send the service call only when editing ends, or debounce/coalesce the changes before writing to Home Assistant.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| func configure(baseURLString: String, token: String) throws { | ||
| guard let url = URL(string: baseURLString.trimmingCharacters(in: .whitespacesAndNewlines)), | ||
| url.scheme == "http" || url.scheme == "https" |
There was a problem hiding this comment.
Either reject HTTP or add an ATS exception
When a user enters a common local Home Assistant URL like http://homeassistant.local:8123, this validation accepts it, but the generated app Info.plist does not define any NSAppTransportSecurity allowance, so iOS URLSession will block the cleartext request during ping(). Either add an ATS/local-network exception for the supported HTTP case or reject HTTP up front so onboarding does not offer a URL that cannot connect.
Useful? React with 👍 / 👎.
|
|
||
| var next = snapshot | ||
| if let v = double(from: state(for: "solar_power")) { next.solarKW = v / 1000.0 } | ||
| if let v = double(from: state(for: "home_power")) { next.homeKW = abs(v) / 1000.0 } |
There was a problem hiding this comment.
Map live data to PowerSync's actual entity IDs
For a connected PowerSync Home Assistant instance, the integration exposes sensor.power_sync_home_load, sensor.power_sync_battery_level, sensor.power_sync_current_import_price, and sensor.power_sync_current_export_price (see the sensor type constants in custom_components/power_sync/const.py), not home_power, battery_soc, general_price, or feed_in_price. As written, refreshes leave the home load, SOC, and prices at demo values even after a successful /api/states fetch; update these suffixes or use a central entity map.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| Section("Operation Mode") { | ||
| Picker("Mode", selection: $model.operationMode) { |
There was a problem hiding this comment.
Send picker changes to Home Assistant
In live mode, changing this picker only mutates the local observable value; there is no onChange or AppModel call to the existing power_sync.set_operation_mode service, and the grid export/charging/storm toggles below follow the same pattern. Users can leave this screen believing the battery configuration changed while Home Assistant remains unchanged, so either wire these controls to the HA services or make them explicitly read-only/demo-only.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| var next = snapshot | ||
| if let v = double(from: state(for: "solar_power")) { next.solarKW = v / 1000.0 } |
There was a problem hiding this comment.
Preserve HA kW units in the live snapshot
The PowerSync HA power sensors are already exposed in kW (for example sensor.power_sync_solar_power is UnitOfPower.KILO_WATT in custom_components/power_sync/sensor.py), so a connected site reporting 5.0 kW solar will be displayed as 0.005 kW here. Keep the snapshot units consistent, and likewise convert the grid/battery kW states before storing them in fields that the UI treats as watts.
Useful? React with 👍 / 👎.
|
|
||
| func forceCharge() async { | ||
| await call(service: "force_charge", data: ["duration": String(forceChargeDuration.rawValue)]) | ||
| statusMessage = "Force charge for \(forceChargeDuration.label)" |
There was a problem hiding this comment.
Report command success only after the service succeeds
When the live Home Assistant service call fails (network error, unauthorized token, or non-2xx response), call() catches the error and only sets connectionError, but this line still shows a successful force-charge alert. Users can be told a force mode is active even though HA rejected it; have call() return success or throw, and set the success message only after that succeeds.
Useful? React with 👍 / 👎.
| else { return } | ||
| defaults.set(data, forKey: snapshotKey) | ||
| WidgetCenter.shared.reloadAllTimelines() | ||
| LiveActivityManager.sync(from: model) |
There was a problem hiding this comment.
Avoid auto-starting demo Live Activities
On a fresh install AppModel.init defaults into demo data and calls reloadDemo(), which writes a snapshot and reaches this line; if no activity exists, LiveActivityManager.sync immediately requests one with demo optimization data before onboarding is complete. Gate Live Activity creation behind an explicit user action or completed live connection, and only update existing activities from background snapshot writes.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| do { | ||
| try await client.configure(baseURLString: url, token: token) |
There was a problem hiding this comment.
Preserve the existing client on failed reconnects
If a live-connected user edits the connection and enters a reachable but invalid URL/token, configure mutates the actor before ping() proves the new credentials, and the catch path does not restore the previous client state. The UI and keychain still point at the old connection, but refreshes and controls now use the failed in-memory credentials until the user reconnects or restarts; stage the new configuration or restore the prior base URL/token on failure.
Useful? React with 👍 / 👎.
| import SwiftUI | ||
| import WidgetKit | ||
|
|
||
| struct OptimizationActivityAttributes: ActivityAttributes { |
There was a problem hiding this comment.
Share one Live Activity attributes type
The app requests Activity<OptimizationActivityAttributes> using the app target's separate definition, while the widget extension registers an ActivityConfiguration for this duplicate extension-local type. Because ActivityKit matches activities by their attributes type, app-created activities can fail to render in the widget extension; put a single attributes source file in both targets instead of defining two independent types.
Useful? React with 👍 / 👎.
|
|
||
| func refreshOptimization() async { | ||
| await refresh() | ||
| optimization.lastOptimized = .now |
There was a problem hiding this comment.
Fetch optimizer data before stamping it refreshed
In live mode, Refresh Now for Smart Optimization only runs the generic state refresh and then overwrites lastOptimized, so the schedule, costs, and actions can remain demo/stale data while the UI claims they were optimized just now. The HA integration already exposes /api/power_sync/optimization; fetch and apply that response, or avoid updating this timestamp when no optimizer payload was loaded.
Useful? React with 👍 / 👎.
- Remove unused `uuid` import from generate_xcodeproj.py - Add fail-fast check for fragile text.replace() in generate_xcodeproj.py - Flip usesDemoMode=true and persist it when falling back to demo on reconnect failure in AppModel.swift - Read app version from CFBundleShortVersionString instead of hard-coding '2.12' in SettingsView.swift - Highlight active TOU slot based on current time instead of hard-coded index 2 in PricesView.swift - Add notConfigured error case for unconfigured client in HomeAssistantClient.swift
* chore(workflow): adopt GitHub-native task tracking Replace Linear-first agent guidance with GitHub Issues and the shared Development Project. Keep pull requests and CI authoritative for implementation. * fix(docs): remove stray workflow marker
…oud setup) (#52) Co-authored-by: Cursor Agent <cursoragent@cursor.com>
* ci(github): install Repo Assist Install the current upstream Copilot workflow, compiled lock file, and agentic-workflow tooling. Use GitHub-native issues, pull requests, and persistent repository memory. * fix(github): satisfy workflow security gates Disable checkout credential persistence and document the generated workflow exceptions required by repository linting. * fix(github): address Repo Assist review findings Authenticate scheduled checks, remove the undefined merge driver, align the Copilot setup action, and clarify generated configuration. * fix(github): use keyless Copilot authentication Use the per-run GitHub token for Copilot inference, keep capacity skips green, and align active task guidance with GitHub Issues. * fix(github): harden Repo Assist pull requests Use trusted labels for automation capacity and branch updates, produce conventional pull request titles, and guarantee three distinct tasks. * fix(github): align Repo Assist maintenance markers Match generated pull request titles and labels, disclose automated status messages, and avoid an undefined merge driver. * fix(github): complete Repo Assist output policy Allow required changelog updates and narrowly permit closing labeled Repo Assist summary issues. * fix(github): serialize Repo Assist runs Prevent overlapping repository runs and suppress noisy no-op and failure issues while preserving visible workflow failures. * fix(github): expose workflow upgrade tools Allow cloud agents to run the fix and upgrade routes advertised by the installed Agentic Workflows skill.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 08a5c914be
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| Button(role: .destructive) { | ||
| Task { await model.goOffGrid() } |
There was a problem hiding this comment.
Require confirmation before islanding the Powerwall
On a live Powerwall install, tapping this destructive button immediately calls powerwall_go_off_grid, so an accidental tap can island the site without any second confirmation; the HA service only checks SOC, not user intent. Please gate this hardware command behind a confirmation dialog before dispatching it.
AGENTS.md reference: AGENTS.md:L3-L4
Useful? React with 👍 / 👎.
| .onChange(of: model.backupReservePercent) { _, newValue in | ||
| Task { await model.setBackupReserve(newValue) } |
There was a problem hiding this comment.
Read the live reserve before sending slider writes
In live mode the reserve slider is initialized from the hard-coded backupReservePercent = 20 and refreshFromHomeAssistant() never loads the actual number.power_sync_tesla_backup_reserve, so a site currently held at, for example, 50% will see 20% and the first drag here sends set_backup_reserve near that stale value, lowering the outage reserve. Disable this write until the live reserve has been read, or fetch it before enabling the slider.
AGENTS.md reference: AGENTS.md:L3-L4
Useful? React with 👍 / 👎.
| import Foundation | ||
|
|
||
| enum Formatters { | ||
| static let currency: FloatingPointFormatStyle<Double>.Currency = .currency(code: "AUD") |
There was a problem hiding this comment.
Derive displayed currency from Home Assistant
For live sites using non-AUD providers that PowerSync supports, such as Octopus (GBP), EPEX (EUR), or NZ tariffs, every cost/savings value is formatted as AUD and the price helper also hard-codes ¢/kWh. The HA sensors already expose currency metadata, so the iOS model should load and use that instead of presenting GBP/EUR/NZD amounts as Australian dollars.
Useful? React with 👍 / 👎.
| func updateSummaryPeriod(_ period: SummaryPeriod) { | ||
| summaryPeriod = period | ||
| energyTotals = DemoDataFactory.makeEnergyTotals(for: period) |
There was a problem hiding this comment.
Fetch live energy history instead of demo totals
When a connected user changes the Day/Week/Month/Year picker, this always overwrites the displayed totals with DemoDataFactory values; it never calls PowerSync's mobile calendar-history endpoint (/api/power_sync/calendar_history or the matching service) that exists for these summaries. Live users therefore see fabricated generation/export totals whenever they switch periods.
Useful? React with 👍 / 👎.
| func rescanBattery() async { | ||
| batteryHealth.lastScan = .now | ||
| statusMessage = "Battery health scan recorded" | ||
| SharedSnapshotWriter.write(from: self) |
There was a problem hiding this comment.
Send battery scans to HA instead of stamping locally
In live mode the "Rescan Battery (TEDAPI)" action only changes the in-memory timestamp and writes the unchanged demo health values to the shared snapshot; it never performs a TEDAPI read or posts to PowerSync's /api/power_sync/battery_health/sync_battery_health path. Users can be told a scan was recorded while Home Assistant and the displayed capacity/health numbers were never updated.
Useful? React with 👍 / 👎.
| get: { | ||
| model.automations.first(where: { $0.id == rule.id })?.isEnabled ?? false | ||
| }, | ||
| set: { model.setAutomation(rule, enabled: $0) } |
There was a problem hiding this comment.
Toggle real automations instead of demo rows
For a live connection, this setter only flips the local demo AutomationRule array; it does not load the real automation list from /api/power_sync/automations or call the /toggle endpoint, whose IDs are backend automation IDs rather than these generated UUIDs. A user can disable a price/grid automation in the app while the Home Assistant automation remains active.
Useful? React with 👍 / 👎.
| func setEVMode(_ mode: EVChargingMode, enabled: Bool) { | ||
| evModes[mode] = enabled |
There was a problem hiding this comment.
Send EV mode changes to the backend
For connected users, changing any EV charging mode only mutates the in-memory evModes dictionary; it never calls the existing PowerSync EV endpoints such as auto-schedule toggle, solar-surplus config, or price-level settings. Users can believe car-charging automation changed while Home Assistant continues running the previous EV policy.
AGENTS.md reference: AGENTS.md:L3-L4
Useful? React with 👍 / 👎.
| var batterySystemName = "Tesla Powerwall" | ||
| var isBatteryAutoDetected = true |
There was a problem hiding this comment.
Detect the actual battery system before enabling controls
On live non-Tesla installations such as Sigenergy, Sungrow, FoxESS, or GoodWe, this remains "Tesla Powerwall" and isBatteryAutoDetected stays true because the app never reads PowerSync's /api/power_sync/backend_config battery_system/capability data. That mislabels the site and leaves Tesla-specific controls like off-grid available for the wrong hardware, so fetch the backend configuration before marking the system auto-detected or showing those controls.
AGENTS.md reference: AGENTS.md:L3-L4
Useful? React with 👍 / 👎.
Summary
Adds a native Swift 6 / SwiftUI PowerSync mobile front end under
ios/, built only with Apple frameworks (no third-party UI or networking libraries) and aligned with Apple HIG + the iOS 26/27 Liquid Glass design language.What’s included
cc.powersync.mobile) with Swift 6 strict concurrencyWidgetKit/ActivityKit)URLSession+ KeychainTabView,NavigationStack,List/Form,Gauge,Picker,Toggle, SF Symbols, Swift Charts,.glassEffect/GlassEffectContainer,tabBarMinimizeBehaviorHow to run
Open
ios/PowerSync.xcodeprojin Xcode 26+ on a Mac, select the PowerSync scheme, set a Development Team (App Groupgroup.cc.powersync.mobile), and run. Details inios/README.md.Design mapping
Grounded in the existing HA integration surface (
services.yaml, optimizer modes, tariff windows, EV modes, TEDAPI battery health) and the mockup set from the prior design pass.