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
8 changes: 8 additions & 0 deletions appleApp/FTW/Push/Notifications.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ enum Notifications {
}
}

/// Whether this iPhone lets FTW notify, for Home's notifications.
static func allowed() async -> UNAuthorizationStatus {
await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
}

/// FTW's own page in the iPhone's Settings, where a refusal is undone.
static var settingsURL: URL? { URL(string: UIApplication.openNotificationSettingsURLString) }

static func received(token: Data) {
#if DEBUG
log.notice("push token \(token.byteArray.hex, privacy: .public) (\(environment.rawValue, privacy: .public))")
Expand Down
14 changes: 13 additions & 1 deletion appleApp/FTW/UI/HomeTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import FTWKit
import SwiftUI

/// Home, the fourth tab: the home itself, in the system's own list. Its
/// cars, its assistants and the watch; then the homes on this
/// cars, its assistants, the watch and its notifications; then the homes on this
/// phone, this phone's own settings, and what FTW is. What the title's
/// menu and the settings sheet held before the tabs, in one place a
/// newcomer finds.
Expand All @@ -25,6 +25,7 @@ struct HomeTabView: View {
if app.watch.canAdd(home) {
WatchSettingsSection(app: app, home: home)
}
notify
homes
phone
price
Expand Down Expand Up @@ -56,6 +57,17 @@ struct HomeTabView: View {
}
}

/// Which notifications the box sends, and whether this iPhone lets FTW
/// notify.
private var notify: some View {
Section {
NavigationLink { NotifySettingsView(home: home) } label: {
Label(L("Notifications"), systemImage: "bell")
}
.accessibilityIdentifier("settings.notify")
}
}

/// The homes this phone holds, the open one ticked, and the way to
/// pair another.
private var homes: some View {
Expand Down
113 changes: 113 additions & 0 deletions appleApp/FTW/UI/NotifySettingsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import FTWKit
import SwiftUI
import UserNotifications

/// Home › Notifications: whether this iPhone lets FTW notify, then the box's
/// own choices, the same ones the web app and the box's settings show. The
/// box decides when to send; these say which kinds it sends at all.
struct NotifySettingsView: View {
let home: HomeModels
@State private var allowed: UNAuthorizationStatus?
@Environment(\.scenePhase) private var scenePhase

private var rules: NotifyRules { home.notify }

var body: some View {
List {
phone
box
if rules.phase == .ready, rules.canChange {
ForEach(NotifyRules.Group.allCases, id: \.self) { group in
let kinds = rules.kinds.filter { NotifyRules.group($0) == group }
if !kinds.isEmpty {
Section(Self.title(group)) {
ForEach(kinds, id: \.self) { kind in
Toggle(NotifyRules.label(kind), isOn: Binding(
get: { rules.on[kind] == true },
set: { value in Task { await rules.set(kind, value) } }
))
.accessibilityIdentifier("notify.\(kind)")
}
}
.disabled(!rules.boxOn)
}
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(Theme.ground)
.navigationTitle(L("Notifications"))
.navigationBarTitleDisplayMode(.inline)
.animation(Theme.spring, value: rules.kinds)
// Read when the view opens and when the session comes back.
.task(id: home.site.session.phase == .streaming) { await rules.load() }
// Back from the iPhone's Settings, the answer may have changed.
.task(id: scenePhase) { allowed = await Notifications.allowed() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register for push after permission is enabled in Settings

If notification permission was denied before this launch, following “Open Settings” and granting permission only refreshes allowed when the user returns; it never registers with APNs. Notifications.start() skips registration for denied permission, and SiteModel.installPushSink() does not reinstall an existing sink when the connection resumes. Consequently, the warning disappears but this phone still receives no notifications until the app is relaunched. When the scene becomes active with permission granted, also register for remote notifications.

Useful? React with 👍 / 👎.

}

/// This iPhone's own answer: asked here if never asked, or the way to
/// Settings if refused.
@ViewBuilder private var phone: some View {
switch allowed {
case .notDetermined?:
Section {
Button {
Task {
await Notifications.askAndRegister()
allowed = await Notifications.allowed()
}
} label: {
Label(L("Allow notifications on this iPhone"), systemImage: "bell.badge")
}
.accessibilityIdentifier("notify.allow")
}
case .denied?:
Section {
Text(L("Notifications for FTW are off in the iPhone's Settings."))
if let url = Notifications.settingsURL {
Link(L("Open Settings"), destination: url)
}
}
default:
EmptyView()
}
}

/// The box's own switch, or why there is none to show.
@ViewBuilder private var box: some View {
Section {
if !home.site.canConfigure {
Text(L("Notifications are chosen by this home's owner.")).foregroundStyle(Theme.ink2)
} else if rules.phase == .oldBox {
Text(AppWords.text("Your box doesn't have that yet — it may be running older software.")).foregroundStyle(Theme.ink2)
} else if rules.phase == .unknown {
Text(L(home.site.session.phase == .streaming ? "Reading your box…" : "Shown when your box is in reach."))
.foregroundStyle(Theme.ink2)
} else {
Toggle(L("Notifications from your box"), isOn: Binding(
get: { rules.boxOn },
set: { value in Task { await rules.setBoxOn(value) } }
))
.accessibilityIdentifier("notify.box")
}
} footer: {
VStack(alignment: .leading, spacing: 4) {
if rules.phase == .ready {
Text(L("Your box decides when to send them, and every owner of this home gets the same. Notices about access come even with this off."))
}
if let problem = rules.problem {
Text(problem).foregroundStyle(Theme.alarm)
}
}
}
}

static func title(_ group: NotifyRules.Group) -> String {
switch group {
case .car: return L("The car")
case .home: return L("The box and its devices")
case .access: return L("Access")
}
}
}
45 changes: 45 additions & 0 deletions appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,8 @@ 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/notifications/rules", .read, nil),
("PUT", "/api/notifications/rules", .configure, nil),
("GET", "/api/cloud", .configure, nil),
("DELETE", "/api/cloud", .configure, nil),
("GET", "/api/cloud/stats-policy", .configure, nil),
Expand Down Expand Up @@ -636,6 +638,44 @@ public final class SimulatedBox {
return [battery, car]
}

/// The box's notification rules, as Core seeds them: off, apart from
/// FTW Cloud, access, which car and a device that does not follow. A
/// template rule the phone does not name rides along untouched.
public private(set) var notifyOn = false
public private(set) var notifyRules: [JSON] = [
["type": "driver_offline", "enabled": false, "threshold_s": 600, "priority": 4, "cooldown_s": 3600],
["type": "charging.connected", "enabled": false, "priority": 3, "cooldown_s": 60],
["type": "charging.session_complete", "enabled": false, "priority": 3],
["type": "charging.interrupted", "enabled": false, "priority": 4, "cooldown_s": 3600],
["type": "charging.goal_at_risk", "enabled": false, "priority": 4],
["type": "update.installed", "enabled": false, "priority": 2],
["type": "driver.offline", "enabled": false, "threshold_s": 600, "priority": 4, "cooldown_s": 3600],
["type": "fuse.over_limit", "enabled": false, "threshold_s": 30, "priority": 5, "cooldown_s": 900],
["type": "control.not_following", "enabled": true, "threshold_s": 600, "priority": 4, "cooldown_s": 3600],
["type": "cloud.granted", "enabled": true, "priority": 3],
["type": "cloud.revoked", "enabled": true, "priority": 3],
["type": "agent.added", "enabled": true, "priority": 3],
["type": "agent.request", "enabled": true, "priority": 4],
["type": "agent.expiring", "enabled": true, "priority": 3],
["type": "approval.key.set", "enabled": true, "priority": 3],
["type": "owner.added", "enabled": true, "priority": 3],
["type": "car.which", "enabled": true, "priority": 3],
]

private func notifyState() -> JSON {
.object([("enabled", .bool(notifyOn)), ("events", .array(notifyRules))])
}

/// The PUT: each entry replaces the rule of its type whole; types not
/// named keep what they had.
private func putNotifyRules(_ body: JSON?) {
if let on = body?["enabled"]?.bool { notifyOn = on }
for e in body?["events"]?.array ?? [] {
guard let type = e["type"]?.string else { continue }
if let i = notifyRules.firstIndex(where: { $0["type"]?.string == type }) { notifyRules[i] = e } else { notifyRules.append(e) }
}
}

private func cloudConsentState() -> JSON {
[
"state": .string(cloudCopyOn ? "on" : "off"), "connected": true,
Expand Down Expand Up @@ -673,6 +713,11 @@ public final class SimulatedBox {
cloudTermsAccepted = false
statisticsOn = false
return (200, cloudConsentState())
case ("GET", "/api/notifications/rules"):
return (200, notifyState())
case ("PUT", "/api/notifications/rules"):
putNotifyRules(body)
return (200, notifyState())
case ("GET", "/api/status"):
let perPhase = house.gridW / 3
return (200, [
Expand Down
3 changes: 3 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public final class HomeModels {
/// The box's answer to "Are we in control?", for the status line and
/// the control sheet.
public let control: ControlCheck
/// Which notifications the box sends, for Home's switches.
public let notify: NotifyRules

/// `cache` is what the app left for its widgets. Each model opens on
/// the sealed home file first, and on these records where it has none.
Expand All @@ -38,6 +40,7 @@ public final class HomeModels {
nodes = NodeModels(site: site, loadpoints: loadpoints, prices: prices, cache: cache)
gridToday = GridTodayShare(site: site, history: nodes.history, prices: prices)
control = ControlCheck(site: site)
notify = NotifyRules(site: site)
// The nodes Now drew, so the next launch draws them in their places
// before its first reading.
site.keep { [weak site, weak loadpoints] home in
Expand Down
30 changes: 30 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,36 @@ public enum NodeWords {
}

static let sv: [String: String] = [
// Notifications, under Home.
"Notifications": "Notiser",
"Allow notifications on this iPhone": "Tillåt notiser på den här iPhonen",
"Notifications for FTW are off in the iPhone's Settings.": "Notiser för FTW är avstängda i iPhonens inställningar.",
"Open Settings": "Öppna Inställningar",
"Notifications from your box": "Notiser från din box",
"Your box decides when to send them, and every owner of this home gets the same. Notices about access come even with this off.": "Boxen avgör när den skickar dem, och alla ägare av hemmet får samma. Notiser om åtkomst kommer även när det här är av.",
"The car": "Bilen",
"The box and its devices": "Boxen och enheterna",
"Access": "Åtkomst",
"Notifications are chosen by this home's owner.": "Notiserna väljs av hemmets ägare.",
"Shown when your box is in reach.": "Visas när boxen går att nå.",
"Reading your box…": "Läser från din box…",
"Not saved. Nothing changed on your box.": "Inte sparat. Inget ändrades på din box.",
"When the car is plugged in": "När bilen kopplas in",
"When the car finishes charging": "När bilen är färdigladdad",
"If the car may not be ready in time": "Om bilen kanske inte blir klar i tid",
"If charging stops before it is done": "Om laddningen avbryts innan den är klar",
"When the box asks which car is plugged in": "När boxen frågar vilken bil som är inkopplad",
"When your box updates itself": "När din box uppdaterar sig",
"If a device goes quiet": "Om en enhet tystnar",
"If the house draws more than the fuse allows": "Om huset drar mer än säkringen klarar",
"If a device does not do what FTW asks": "Om en enhet inte gör som FTW ber",
"When FTW Cloud is turned on": "När FTW Cloud slås på",
"When FTW Cloud is turned off": "När FTW Cloud stängs av",
"When an assistant or Sourceful gets access": "När en assistent eller Sourceful får åtkomst",
"When an assistant asks to change a setting": "När en assistent vill ändra en inställning",
"When an assistant's or Sourceful's access is about to end": "När en assistents eller Sourcefuls åtkomst snart upphör",
"When a phone sets up the key that approves access": "När en telefon sätter upp nyckeln som godkänner åtkomst",
"When a phone is paired as an owner": "När en telefon paras som ägare",
"History and live readings": "Historik och livevärden",
"Swipe up or down to move through time. Actions change the zoom.": "Svep uppåt eller nedåt för att flytta tiden. Åtgärder ändrar zoom.",
"Zoom in": "Zooma in",
Expand Down
Loading
Loading