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
2 changes: 1 addition & 1 deletion appleApp/FTW.xcconfig
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
FTW_APP_GROUP = group.energy.ftw.app

// An extension's version must match its app's.
MARKETING_VERSION = 0.4.9
MARKETING_VERSION = 0.4.10
CURRENT_PROJECT_VERSION = 1

IPHONEOS_DEPLOYMENT_TARGET = 26.0
Expand Down
13 changes: 12 additions & 1 deletion appleApp/FTW/About/AboutView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import SwiftUI
/// the license texts the build copied in from the repository root.
struct AboutView: View {
enum Start: Hashable, Identifiable {
case about, remove
case about, remove, statistics, cloudTerms
case document(LicenseText.Document)

var id: Self { self }
Expand All @@ -14,6 +14,8 @@ struct AboutView: View {
switch name {
case "about": self = .about
case "remove": self = .remove
case "statistics": self = .statistics
case "cloud-terms": self = .cloudTerms
default:
guard let document = LicenseText.Document(rawValue: name) else { return nil }
self = .document(document)
Expand All @@ -24,10 +26,13 @@ struct AboutView: View {
enum Route: Hashable {
case document(LicenseText.Document)
case remove
case statistics
case cloudTerms
}

let app: AppModel
@State private var path: [Route]
@State private var reviewConsent = CloudConsent()
@Environment(\.dismiss) private var dismiss
@Environment(\.dynamicTypeSize) private var typeSize
@ScaledMetric(relativeTo: .title2) private var markSize: CGFloat = 52
Expand All @@ -38,6 +43,8 @@ struct AboutView: View {
switch start {
case .about: _path = State(initialValue: [])
case .remove: _path = State(initialValue: [.remove])
case .statistics: _path = State(initialValue: [.statistics])
case .cloudTerms: _path = State(initialValue: [.cloudTerms])
case .document(let document): _path = State(initialValue: [.document(document)])
}
}
Expand Down Expand Up @@ -134,6 +141,10 @@ struct AboutView: View {
switch route {
case .document(let document):
LicenseView(document: document, source: source)
case .statistics:
CloudSettingsView(app:app)
case .cloudTerms:
CloudConsentView(app:app, consent:reviewConsent)
case .remove:
RemovePhoneView(app: app) { dismiss() }
}
Expand Down
65 changes: 43 additions & 22 deletions appleApp/FTW/Cloud/CloudConsentView.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import FTWKit
import SwiftUI

/// The one screen, at first start: the app uses FTW Cloud, and statistics
/// are a separate choice, off until ticked. An owner's phone turns FTW Cloud
/// on at the box when the person goes on; a viewer is told to ask the owner.
/// Current Cloud terms at first start. Statistics come with Cloud;
/// an owner grants the scope, while a viewer accepts it for this phone.
struct CloudConsentView: View {
let app: AppModel
@Bindable var consent: CloudConsent
var onPausedChoice: (() -> Void)? = nil
@Environment(\.dynamicTypeSize) private var typeSize

private var audience: CloudConsent.Audience {
app.site.map(CloudConsent.audience) ?? .unknown
Expand All @@ -24,28 +25,26 @@ struct CloudConsentView: View {
Text(CloudWords.text("The FTW app uses FTW Cloud"))
.font(.title2.weight(.bold))
Text(CloudWords.text("FTW Cloud is part of the service. It keeps a copy of your home, signed by your box, so the app opens at once and stays up to date."))
Text(CloudWords.text("Your box still decides. Commands go straight to it, never through FTW Cloud."))
Text(CloudWords.text("Your box still decides. Commands stay encrypted between this phone and your box."))
.foregroundStyle(Theme.fgDim)
}
.padding(.vertical, 4)
}

switch audience {
case .viewer:
Section {
Text(CloudWords.text(CloudConsent.included))
Text(CloudWords.text(CloudConsent.statisticsDetails))
Text(CloudWords.text(CloudConsent.statisticsPrivacy))
if !consent.statisticsReady { Text(CloudWords.text(CloudConsent.statisticsWaiting)) }
if let help = consent.help { Text(help).foregroundStyle(Theme.alarm) }
}
if audience == .viewer {
Section {
Label(CloudWords.text("Ask the owner of this home to turn on FTW Cloud."), systemImage: "person.crop.circle.badge.questionmark")
}
case .owner, .ownerLater, .unknown:
} else if audience == .ownerLater {
Section {
Toggle(CloudWords.text("Contribute to statistics"), isOn: $consent.contributeStats)
.tint(Theme.live)
} footer: {
Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on."))
}
if audience == .ownerLater {
Section {
Label(CloudWords.text("Your box turns on FTW Cloud after its next update."), systemImage: "arrow.down.circle")
}
Label(CloudWords.text("Update your box before accepting the new Cloud terms."), systemImage: "arrow.down.circle")
}
}

Expand All @@ -56,19 +55,41 @@ struct CloudConsentView: View {
} footer: {
Text(CloudWords.text("You can turn FTW Cloud off in About."))
}
if typeSize.isAccessibilitySize { Section { actions } }
}
.scrollContentBackground(.hidden)
.background(Theme.surface)
.safeAreaInset(edge: .bottom) {
Button(CloudWords.text("Continue")) {
Task { await consent.accept(app.site) }
if !typeSize.isAccessibilitySize {
actions.padding(16).background(Theme.surface)
}
.buttonStyle(FTWButtonStyle(kind: .primary))
.padding(16)
.background(Theme.surface)
}
.task(id: audience) {
if !app.cloudPaused, let site = app.site, audience == .owner { await consent.refreshStatisticsPolicy(site) }
}
.animation(Theme.spring, value: audience)
.sensoryFeedback(.success, trigger: consent.answered)
.interactiveDismissDisabled()
.interactiveDismissDisabled(!app.cloudPaused)
}

private var actions: some View {
VStack(spacing: 8) {
Button(CloudWords.text("Accept Cloud terms")) {
if app.cloudPaused, let id=app.site?.siteId {
consent.prepareReconnect(id)
onPausedChoice?()
app.resumeCloud()
} else { Task { await consent.accept(app.site) } }
}
.buttonStyle(FTWButtonStyle(kind: .primary))
.disabled(consent.working || (!app.cloudPaused && (audience == .unknown || audience == .ownerLater || (audience == .owner && !consent.policyAvailable))))
Button(CloudWords.text("Use the box locally")) {
if let id=app.site?.siteId { consent.cancelReconnect(id) }
app.pauseCloud()
onPausedChoice?()
}
.buttonStyle(FTWButtonStyle(kind: .quiet))
}
}

}
40 changes: 25 additions & 15 deletions appleApp/FTW/Cloud/CloudGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import FTWKit
import SwiftUI

extension View {
/// The FTW Cloud consent, once, over a real home, and the owner's grant
/// at the box once the box can take it. RootView's one line.
/// The current Cloud terms, accepted for each real home before its owner
/// grants the new scope. Opening a screen never creates a grant.
func cloudConsent(_ app: AppModel) -> some View {
modifier(CloudGate(app: app))
}
Expand All @@ -12,24 +12,34 @@ extension View {
private struct CloudGate: ViewModifier {
let app: AppModel
@State private var consent = CloudConsent()
@State private var reviewingPaused = false

func body(content: Content) -> some View {
content
.sheet(isPresented: Binding(get: { !app.needsPairing && consent.shouldAsk(app.site) }, set: { _ in })) {
CloudConsentView(app: app, consent: consent)
Group {
if app.cloudPaused && !app.needsPairing {
ContentUnavailableView {
Label(CloudWords.text("FTW Cloud"), systemImage: "cloud.slash")
} description: {
Text(CloudWords.text("This phone has stopped using FTW Cloud. Your box keeps working locally."))
} actions: {
Button(CloudWords.text("Review Cloud terms")) { reviewingPaused = true }
Menu(CloudWords.text("Homes")) {
ForEach(app.homes) { home in
Button(HomeName.title(home.label)) { app.open(home.siteId) }
}
Button(CloudWords.text("Pair a home")) { app.recovering = true }
}
}
} else { content }
}
.sheet(isPresented: Binding(get: { reviewingPaused || (!app.cloudPaused && !app.needsPairing && consent.shouldAsk(app.site)) }, set: { shown in if !shown { reviewingPaused=false } })) {
CloudConsentView(app: app, consent: consent, onPausedChoice: { reviewingPaused=false })
.presentationDragIndicator(.hidden)
.tint(Theme.accent)
}
// When the box answers, an owner who agreed turns FTW Cloud on
// there, once per home.
.task(id: ensureKey) {
guard let site = app.site else { return }
await consent.ensure(site)
.task(id: "\(app.site?.siteId ?? "")-\(app.site?.heardFromBox ?? false)-\(app.cloudPaused)") {
guard !app.cloudPaused, let site=app.site else { return }
await consent.finishPendingAcceptance(site)
}
}

private var ensureKey: String {
guard let site = app.site else { return "" }
return "\(site.siteId ?? "")-\(site.heardFromBox)-\(site.cloudOn)-\(consent.answered)"
}
}
48 changes: 26 additions & 22 deletions appleApp/FTW/Cloud/CloudSettingsView.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import FTWKit
import SwiftUI

/// FTW Cloud in About: on or off, and the receipt once the copy is erased.
/// FTW Cloud in About: current terms and one choice for the whole service.
/// An owner changes it at the box over Noise; a viewer sees it.
struct CloudSettingsView: View {
let app: AppModel
@State private var consent = CloudConsent()
@State private var stopping = false

var body: some View {
List {
if app.isDemo {
Section { Text(CloudWords.text("Demo: no reports leave this phone.")) }
}
if let site = app.site {
switch CloudConsent.audience(site) {
case .owner:
Expand All @@ -33,8 +37,16 @@ struct CloudSettingsView: View {
.background(Theme.surface)
.navigationTitle(CloudWords.text("FTW Cloud"))
.navigationBarTitleDisplayMode(.inline)
.task {
guard let site = app.site else { return }
.confirmationDialog(CloudWords.text("Turn FTW Cloud off?"), isPresented: $stopping, titleVisibility: .visible) {
Button(CloudWords.text("Turn off"), role: .destructive) {
Task { if let site = app.site, await consent.turnOff(site) { app.pauseCloud() } }
}
Button(CloudWords.text("Cancel"), role: .cancel) { }
} message: {
Text(CloudWords.text("Turning FTW Cloud off stops remote access for phones and assistants. The box keeps working locally. You may need to pair phones again later. Check the local box for the deletion receipt."))
}
.task(id: app.site.map(CloudConsent.audience)) {
guard let site = app.site, CloudConsent.audience(site) == .owner else { return }
await consent.refresh(site)
}
}
Expand All @@ -46,7 +58,7 @@ struct CloudSettingsView: View {
Section {
Toggle(CloudWords.text("FTW Cloud"), isOn: Binding(get: { on }, set: { want in
Task {
if want { await consent.turnOn(site) } else { await followErasure(site) }
if want { await consent.turnOn(site) } else { stopping = true }
}
}))
.tint(Theme.live)
Expand All @@ -71,19 +83,21 @@ struct CloudSettingsView: View {
}
} footer: {
VStack(alignment: .leading, spacing: 4) {
Text(CloudWords.text("Turning it off erases the copy in FTW Cloud. Your box shows when it is gone."))
Text(CloudWords.text("Turning it off ends remote access and requests deletion. Check the local box for the receipt."))
if let help = consent.help { Text(help).foregroundStyle(Theme.alarm) }
}
}
if on {
Section {
Toggle(CloudWords.text("Contribute to statistics"), isOn: Binding(get: { box?.stats ?? false }, set: { want in
Task { await consent.setStats(want, site) }
}))
.tint(Theme.live)
.disabled(consent.working)
} footer: {
Text(CloudWords.text("Hourly energy totals, without your name or address, help make FTW better. Off unless you turn it on."))
Label(CloudWords.text("Statistics are included with FTW Cloud"), systemImage: "chart.bar")
if box?.termsAccepted != true {
Button(CloudWords.text("Accept Cloud terms")) { Task { await consent.accept(site) } }
.disabled(consent.working || !consent.policyAvailable)
}
Text(CloudWords.text(CloudConsent.included))
Text(CloudWords.text(CloudConsent.statisticsDetails))
Text(CloudWords.text(CloudConsent.statisticsPrivacy))
if !consent.statisticsReady { Text(CloudWords.text(CloudConsent.statisticsWaiting)) }
}
}
}
Expand All @@ -93,14 +107,4 @@ struct CloudSettingsView: View {
transport == Contract.transportQUIC ? "QUIC" : "WebSocket"
}

/// Off, then the receipt: the box has it once the service confirms the
/// copy is gone, usually within seconds. Asked a few times after the
/// tap, never on a timer of its own.
private func followErasure(_ site: SiteModel) async {
guard await consent.turnOff(site) else { return }
for _ in 0..<5 where consent.box?.erasing == true {
try? await Task.sleep(for: .seconds(2))
await consent.refresh(site)
}
}
}
47 changes: 46 additions & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ public final class SimulatedBox {
/// A box linked to FTW Cloud: hello carries its session information,
/// with the table of opaque car ids for the cars it keeps then.
public var linkedToCloud = false
public var statisticsAvailable = true
private var statisticsOn = false
private var cloudCopyOn = true
private var cloudTermsAccepted = false
/// Every hello, and every sub, as they came.
public private(set) var hellos = 0
public private(set) var subs = 0
Expand Down Expand Up @@ -212,7 +216,7 @@ public final class SimulatedBox {
("mode", .text(booting ? "booting" : "full")),
("box", .map([("id", .text("demo-box")), ("build", .text("0.131.0-demo")), ("tz", .text(house.timeZone.identifier))])),
("clock", .map([("source", .text("ntp")), ("syncedAtMs", .number(scheduler.nowMs - 60_000)), ("uptimeMs", .number(uptimeMs))])),
("caps", .array((Self.caps + moreCaps + (cars == nil ? [] : [Contract.capDerCar])).map { .text($0) })),
("caps", .array((Self.caps + [Contract.capCloudLink] + moreCaps + (cars == nil ? [] : [Contract.capDerCar])).map { .text($0) })),
("capsHash", .text("demo")),
("modes", .array(Self.modes.map { .map([("key", .text($0.key)), ("label", .text($0.label)), ("tooltip", .text($0.tooltip)), ("tier", .text($0.tier))]) })),
("role", .text(Contract.roleOwner)),
Expand Down Expand Up @@ -468,6 +472,13 @@ public final class SimulatedBox {
/// Priced beside the route, never from the method, as the box does.
static let routes: [(method: String, path: String, tier: Tier, op: String?)] = [
("GET", "/api/status", .read, nil),
("GET", "/api/cloud", .configure, nil),
("DELETE", "/api/cloud", .configure, nil),
("GET", "/api/cloud/stats-policy", .configure, nil),
("POST", "/api/cloud/grant", .configure, nil),
("DELETE", "/api/cloud/grant", .configure, nil),
("POST", "/api/cloud/stats", .configure, nil),
("DELETE", "/api/cloud/stats", .configure, nil),
("GET", "/api/energy/daily", .read, nil),
("GET", "/api/savings/daily", .read, nil),
("GET", "/api/loadpoints", .read, nil),
Expand Down Expand Up @@ -574,9 +585,43 @@ public final class SimulatedBox {
return [battery, car]
}

private func cloudConsentState() -> JSON {
[
"state": .string(cloudCopyOn ? "on" : "off"), "connected": true,
"cloudPolicy": .string(CloudConsent.policy), "cloudTermsAccepted": .bool(cloudTermsAccepted),
"statisticsPolicy": .string(CloudConsent.statsPolicy),
"statisticsActive": .bool(statisticsOn), "statisticsReady": .bool(statisticsAvailable),
]
}

private func answer(_ method: String, _ pattern: String, _ itemID: String?, _ query: [String: String], _ body: JSON?) -> (Int, JSON) {
let now = scheduler.nowMs
switch (method, pattern) {
case ("GET", "/api/cloud/stats-policy"):
return (200, ["policy": .string(CloudConsent.statsPolicy), "cloud_policy": .string(CloudConsent.policy), "cloud_includes_statistics": true, "available": .bool(statisticsAvailable)])
case ("GET", "/api/cloud"):
return (200, cloudConsentState())
case ("POST", "/api/cloud/grant"):
guard body?["policy"]?.string == CloudConsent.policy else { return (400, ["code": "E_UNAVAILABLE"]) }
cloudCopyOn = true
cloudTermsAccepted = true
statisticsOn = true
return (200, cloudConsentState())
case ("POST", "/api/cloud/stats"):
guard statisticsAvailable, body?["policy"]?.string == CloudConsent.statsPolicy else {
return (409, ["code": "E_UNAVAILABLE"])
}
statisticsOn = true
return (200, cloudConsentState())
case ("DELETE", "/api/cloud/stats"):
guard !cloudTermsAccepted else { return (409, ["code": "E_UNAVAILABLE"]) }
statisticsOn = false
return (200, cloudConsentState())
case ("DELETE", "/api/cloud"), ("DELETE", "/api/cloud/grant"):
cloudCopyOn = false
cloudTermsAccepted = false
statisticsOn = false
return (200, cloudConsentState())
case ("GET", "/api/status"):
let perPhase = house.gridW / 3
return (200, [
Expand Down
Loading
Loading