Add PhoneConnectivityBadge, ProfileSectionView, SyncableSettingSectionView, StatusMessageView (+24 more)

This commit is contained in:
Matt Bruce 2026-01-16 15:53:15 -06:00
parent d21fbd577f
commit f0823a22c5
11 changed files with 481 additions and 77 deletions

View File

@ -12,8 +12,53 @@ struct ContentView: View {
@State private var store = WatchProfileStore.shared @State private var store = WatchProfileStore.shared
var body: some View { var body: some View {
VStack { VStack(alignment: .leading) {
if let profile = store.profile { PhoneConnectivityBadge(isReachable: store.isPhoneReachable)
ProfileSectionView(profile: store.profile)
Divider()
SyncableSettingSectionView(
value: store.syncValue,
updatedAt: store.syncUpdatedAt
)
if !store.statusMessage.isEmpty {
StatusMessageView(message: store.statusMessage)
}
}
.padding()
}
}
#Preview {
ContentView()
}
private struct PhoneConnectivityBadge: View {
let isReachable: Bool
var body: some View {
if !isReachable {
Text("Open iPhone App to Sync")
.font(.caption2)
.foregroundStyle(Color.Status.warning)
.padding(.horizontal, WatchDesign.Spacing.small)
.padding(.vertical, WatchDesign.Spacing.xSmall)
.background(Color.Status.warning.opacity(WatchDesign.Opacity.subtle))
.clipShape(.rect(cornerRadius: WatchDesign.CornerRadius.pill))
}
}
}
private struct ProfileSectionView: View {
let profile: UserProfile?
var body: some View {
VStack(alignment: .leading) {
Text("User Profile")
.font(.caption)
.foregroundStyle(.secondary)
if let profile {
Text(profile.name) Text(profile.name)
.bold() .bold()
Text(profile.email) Text(profile.email)
@ -30,17 +75,43 @@ struct ContentView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if !store.statusMessage.isEmpty {
Text(store.statusMessage)
.font(.caption2)
.foregroundStyle(.secondary)
}
} }
.padding()
} }
} }
#Preview { private struct SyncableSettingSectionView: View {
ContentView() let value: String?
let updatedAt: Date?
var body: some View {
VStack(alignment: .leading) {
Text("Syncable Setting")
.font(.caption)
.foregroundStyle(.secondary)
if let value {
Text(value)
.font(.system(.caption, design: .monospaced))
if let updatedAt {
Text(updatedAt, format: .dateTime)
.font(.caption2)
.foregroundStyle(.secondary)
}
} else {
Text("No sync value received")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private struct StatusMessageView: View {
let message: String
var body: some View {
Text(message)
.font(.caption2)
.foregroundStyle(.secondary)
}
} }

View File

@ -0,0 +1,22 @@
import SwiftUI
enum WatchDesign {
enum Spacing {
static let xSmall: CGFloat = 4
static let small: CGFloat = 8
}
enum CornerRadius {
static let pill: CGFloat = 12
}
enum Opacity {
static let subtle: Double = 0.2
}
}
extension Color {
enum Status {
static let warning = Color.orange
}
}

View File

@ -0,0 +1,29 @@
import Foundation
import SharedKit
@MainActor
final class SyncableSettingWatchHandler: WatchDataHandling {
let key = StorageKeyNames.syncableSetting
private let store: WatchProfileStore
private let decoder = JSONDecoder()
init(store: WatchProfileStore) {
self.store = store
}
convenience init() {
self.init(store: .shared)
}
func handle(data: Data) {
do {
let value = try decoder.decode(String.self, from: data)
store.setSyncValue(value)
Logger.debug("Watch synced syncable setting")
} catch {
store.setStatus("Failed to decode sync value")
Logger.error("Watch failed to decode syncable setting", error: error)
}
}
}

View File

@ -20,8 +20,10 @@ final class UserProfileWatchHandler: WatchDataHandling {
do { do {
let profile = try decoder.decode(UserProfile.self, from: data) let profile = try decoder.decode(UserProfile.self, from: data)
store.setProfile(profile) store.setProfile(profile)
Logger.debug("Watch synced user profile")
} catch { } catch {
store.setStatus("Failed to decode profile") store.setStatus("Failed to decode profile")
Logger.error("Watch failed to decode user profile", error: error)
} }
} }
} }

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import SharedKit
import WatchConnectivity import WatchConnectivity
@MainActor @MainActor
@ -19,6 +20,9 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
private func registerDefaultHandlers() { private func registerDefaultHandlers() {
let profileHandler = UserProfileWatchHandler(store: store) let profileHandler = UserProfileWatchHandler(store: store)
registerHandler(profileHandler) registerHandler(profileHandler)
let syncableSettingHandler = SyncableSettingWatchHandler(store: store)
registerHandler(syncableSettingHandler)
} }
func registerHandler(_ handler: WatchDataHandling) { func registerHandler(_ handler: WatchDataHandling) {
@ -37,10 +41,15 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
private func loadCurrentContext() { private func loadCurrentContext() {
guard WCSession.isSupported() else { return } guard WCSession.isSupported() else { return }
handleContext(WCSession.default.applicationContext) let context = WCSession.default.applicationContext
let keys = context.keys.sorted().joined(separator: ", ")
Logger.debug("Watch loaded current context keys: [\(keys)]")
handleContext(context)
} }
private func handleContext(_ context: [String: Any]) { private func handleContext(_ context: [String: Any]) {
let keys = context.keys.sorted().joined(separator: ", ")
Logger.debug("Watch handling context keys: [\(keys)]")
for (key, handler) in handlers { for (key, handler) in handlers {
guard let data = context[key] as? Data else { continue } guard let data = context[key] as? Data else { continue }
handler.handle(data: data) handler.handle(data: data)
@ -52,10 +61,49 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
activationDidCompleteWith activationState: WCSessionActivationState, activationDidCompleteWith activationState: WCSessionActivationState,
error: Error? error: Error?
) { ) {
if let error {
Logger.error("Watch WCSession activation failed", error: error)
} else {
Logger.debug("Watch WCSession activated with state: \(activationState.rawValue)")
}
updateReachability(using: session)
loadCurrentContext() loadCurrentContext()
requestSyncIfNeeded()
} }
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
Logger.debug("Watch received application context with \(applicationContext.count) keys")
handleContext(applicationContext) handleContext(applicationContext)
} }
func sessionReachabilityDidChange(_ session: WCSession) {
Logger.debug("Watch reachability changed: reachable=\(session.isReachable)")
updateReachability(using: session)
requestSyncIfNeeded()
}
private func updateReachability(using session: WCSession) {
store.setPhoneReachable(session.isReachable)
}
private func requestSyncIfNeeded() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
if session.isReachable {
store.setPhoneReachable(true)
store.setStatus("Requesting sync from iPhone...")
session.sendMessage([WatchSyncMessageKeys.requestSync: true]) { reply in
Logger.debug("Watch received sync reply with \(reply.count) keys")
self.handleContext(reply)
} errorHandler: { error in
Logger.error("Watch sync request failed", error: error)
}
Logger.debug("Watch requested sync from iPhone (reachable)")
} else {
store.setPhoneReachable(false)
store.setStatus("Open the iPhone app to sync.")
session.transferUserInfo([WatchSyncMessageKeys.requestSync: true])
Logger.debug("Watch queued sync request for iPhone launch")
}
}
} }

View File

@ -8,6 +8,9 @@ final class WatchProfileStore {
static let shared = WatchProfileStore() static let shared = WatchProfileStore()
private(set) var profile: UserProfile? private(set) var profile: UserProfile?
private(set) var syncValue: String?
private(set) var syncUpdatedAt: Date?
private(set) var isPhoneReachable = false
private(set) var statusMessage: String = "" private(set) var statusMessage: String = ""
private init() {} private init() {}
@ -17,6 +20,16 @@ final class WatchProfileStore {
statusMessage = "Profile synced" statusMessage = "Profile synced"
} }
func setSyncValue(_ value: String) {
syncValue = value
syncUpdatedAt = Date()
statusMessage = "Syncable setting updated"
}
func setPhoneReachable(_ isReachable: Bool) {
isPhoneReachable = isReachable
}
func setStatus(_ message: String) { func setStatus(_ message: String) {
statusMessage = message statusMessage = message
} }

View File

@ -56,10 +56,12 @@ struct SecureStorageSampleApp: App {
ExternalKeyMaterialProvider(), ExternalKeyMaterialProvider(),
for: SampleKeyMaterialSources.external for: SampleKeyMaterialSources.external
) )
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
} }
#if DEBUG #if DEBUG
let report = StorageAuditReport.renderText(AppStorageCatalog()) // Disabled to keep console focused on sync logs.
print(report) // let report = StorageAuditReport.renderText(AppStorageCatalog())
// print(report)
#endif #endif
} }

View File

@ -1,4 +1,6 @@
import Foundation import Foundation
import LocalData
import SharedKit
import WatchConnectivity import WatchConnectivity
@MainActor @MainActor
@ -22,7 +24,14 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
activationDidCompleteWith activationState: WCSessionActivationState, activationDidCompleteWith activationState: WCSessionActivationState,
error: Error? error: Error?
) { ) {
// Intentionally empty: activation state is handled by WCSession. if let error {
Logger.error("iOS WCSession activation failed", error: error)
} else {
Logger.debug("iOS WCSession activated with state: \(activationState.rawValue)")
}
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
} }
func sessionDidBecomeInactive(_ session: WCSession) { func sessionDidBecomeInactive(_ session: WCSession) {
@ -32,4 +41,74 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
func sessionDidDeactivate(_ session: WCSession) { func sessionDidDeactivate(_ session: WCSession) {
session.activate() session.activate()
} }
func sessionWatchStateDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession watch state changed: paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)")
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
func sessionReachabilityDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession reachability changed: reachable=\(session.isReachable)")
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request")
Task {
let snapshot = await StorageRouter.shared.syncSnapshot()
if snapshot.isEmpty {
Logger.debug("iOS sync snapshot empty; falling back to application context")
}
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
func session(
_ session: WCSession,
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request (reply)")
Task {
let payload = await buildSyncReplyPayload()
replyHandler(payload)
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
private func buildSyncReplyPayload() async -> [String: Any] {
let maxAttempts = 3
for attempt in 1...maxAttempts {
let snapshot = await StorageRouter.shared.syncSnapshot()
if !snapshot.isEmpty {
Logger.debug("iOS sync reply snapshot ready on attempt \(attempt)")
return snapshot
}
if attempt < maxAttempts {
Logger.debug("iOS sync reply snapshot empty; retrying (\(attempt))")
try? await Task.sleep(for: .milliseconds(300))
}
}
Logger.debug("iOS sync reply snapshot empty after retries; replying with ack only")
return ["ack": true]
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
if let request = userInfo[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received queued watch sync request")
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
}
} }

View File

@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import LocalData import LocalData
import WatchConnectivity
@MainActor @MainActor
struct PlatformSyncDemo: View { struct PlatformSyncDemo: View {
@ -16,6 +17,7 @@ struct PlatformSyncDemo: View {
@State private var isLoading = false @State private var isLoading = false
@State private var selectedPlatform: PlatformAvailability = .all @State private var selectedPlatform: PlatformAvailability = .all
@State private var selectedSync: SyncPolicy = .never @State private var selectedSync: SyncPolicy = .never
@State private var watchStatus = WatchStatus.current()
@FocusState private var isFieldFocused: Bool @FocusState private var isFieldFocused: Bool
var body: some View { var body: some View {
@ -26,6 +28,10 @@ struct PlatformSyncDemo: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Section("Watch Status") {
WatchStatusView(status: watchStatus)
}
Section("Platform Availability") { Section("Platform Availability") {
Picker("Available On", selection: $selectedPlatform) { Picker("Available On", selection: $selectedPlatform) {
Text("All (iPhone + Watch)").tag(PlatformAvailability.all) Text("All (iPhone + Watch)").tag(PlatformAvailability.all)
@ -35,7 +41,7 @@ struct PlatformSyncDemo: View {
} }
.pickerStyle(.menu) .pickerStyle(.menu)
platformDescription PlatformAvailabilityDescriptionView(availability: selectedPlatform)
} }
Section("Sync Policy") { Section("Sync Policy") {
@ -46,7 +52,7 @@ struct PlatformSyncDemo: View {
} }
.pickerStyle(.menu) .pickerStyle(.menu)
syncDescription SyncPolicyDescriptionView(syncPolicy: selectedSync)
} }
Section("Test Data") { Section("Test Data") {
@ -97,13 +103,29 @@ struct PlatformSyncDemo: View {
} }
} }
Section("Expected Result") {
ExpectedOutcomeView(
availability: selectedPlatform,
syncPolicy: selectedSync
)
}
Section("Current Configuration") { Section("Current Configuration") {
LabeledContent("Platform", value: selectedPlatform.displayName) LabeledContent("Platform", value: selectedPlatform.displayName)
LabeledContent("Sync", value: selectedSync.displayName) LabeledContent("Sync", value: selectedSync.displayName)
LabeledContent("Max Auto-Sync Size", value: "100 KB") LabeledContent("Max Auto-Sync Size", value: "50 KB")
}
Section("Watch Notes") {
Text("The watch app only shows data it is configured to handle. For this demo, the watch displays the syncable setting and user profile.")
.font(.caption)
.foregroundStyle(.secondary)
} }
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.task {
refreshWatchStatus()
}
.toolbar { .toolbar {
ToolbarItemGroup(placement: .keyboard) { ToolbarItemGroup(placement: .keyboard) {
Spacer() Spacer()
@ -114,46 +136,6 @@ struct PlatformSyncDemo: View {
} }
} }
@ViewBuilder
private var platformDescription: some View {
switch selectedPlatform {
case .all:
Text("Data accessible on both iPhone and Apple Watch")
.font(.caption)
.foregroundStyle(.secondary)
case .phoneOnly:
Text("Data only accessible on iPhone. Watch access throws error.")
.font(.caption)
.foregroundStyle(.secondary)
case .watchOnly:
Text("Data only accessible on Watch. iPhone access throws error.")
.font(.caption)
.foregroundStyle(.secondary)
case .phoneWithWatchSync:
Text("Stored on iPhone, synced to Watch via WatchConnectivity")
.font(.caption)
.foregroundStyle(.secondary)
}
}
@ViewBuilder
private var syncDescription: some View {
switch selectedSync {
case .never:
Text("Data stays local, never synced")
.font(.caption)
.foregroundStyle(.secondary)
case .manual:
Text("Sync triggered explicitly by app code")
.font(.caption)
.foregroundStyle(.secondary)
case .automaticSmall:
Text("Auto-sync if data ≤ 100KB, otherwise throws error")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func saveValue() { private func saveValue() {
isLoading = true isLoading = true
Task { Task {
@ -165,11 +147,12 @@ struct PlatformSyncDemo: View {
try await StorageRouter.shared.set(settingValue, for: key) try await StorageRouter.shared.set(settingValue, for: key)
statusMessage = "✓ Saved with \(selectedPlatform.displayName) availability and \(selectedSync.displayName) sync" statusMessage = "✓ Saved with \(selectedPlatform.displayName) availability and \(selectedSync.displayName) sync"
} catch StorageError.dataTooLargeForSync { } catch StorageError.dataTooLargeForSync {
statusMessage = "Error: Data too large for automatic sync (max 100KB)" statusMessage = "Error: Data too large for automatic sync (max 50KB)"
} catch { } catch {
statusMessage = "Error: \(error.localizedDescription)" statusMessage = "Error: \(error.localizedDescription)"
} }
isLoading = false isLoading = false
refreshWatchStatus()
} }
} }
@ -183,7 +166,7 @@ struct PlatformSyncDemo: View {
) )
let value = try await StorageRouter.shared.get(key) let value = try await StorageRouter.shared.get(key)
storedValue = value storedValue = value
settingValue = value // Sync to field settingValue = value
statusMessage = "✓ Loaded value" statusMessage = "✓ Loaded value"
} catch StorageError.notFound { } catch StorageError.notFound {
storedValue = "" storedValue = ""
@ -192,13 +175,13 @@ struct PlatformSyncDemo: View {
statusMessage = "Error: \(error.localizedDescription)" statusMessage = "Error: \(error.localizedDescription)"
} }
isLoading = false isLoading = false
refreshWatchStatus()
} }
} }
private func testPlatformError() { private func testPlatformError() {
isLoading = true isLoading = true
Task { Task {
// Try to access a watchOnly key from iPhone
let key = StorageKeys.WatchVibrationKey() let key = StorageKeys.WatchVibrationKey()
do { do {
_ = try await StorageRouter.shared.get(key) _ = try await StorageRouter.shared.get(key)
@ -209,8 +192,13 @@ struct PlatformSyncDemo: View {
statusMessage = "Error: \(error.localizedDescription)" statusMessage = "Error: \(error.localizedDescription)"
} }
isLoading = false isLoading = false
refreshWatchStatus()
} }
} }
private func refreshWatchStatus() {
watchStatus = WatchStatus.current()
}
} }
// MARK: - Display Names // MARK: - Display Names
@ -241,3 +229,147 @@ extension SyncPolicy {
PlatformSyncDemo() PlatformSyncDemo()
} }
} }
private struct WatchStatus: Equatable {
let isSupported: Bool
let isPaired: Bool
let isWatchAppInstalled: Bool
let isReachable: Bool
let activationState: WCSessionActivationState?
init(
isSupported: Bool = false,
isPaired: Bool = false,
isWatchAppInstalled: Bool = false,
isReachable: Bool = false,
activationState: WCSessionActivationState? = nil
) {
self.isSupported = isSupported
self.isPaired = isPaired
self.isWatchAppInstalled = isWatchAppInstalled
self.isReachable = isReachable
self.activationState = activationState
}
static func current() -> WatchStatus {
guard WCSession.isSupported() else {
return WatchStatus(isSupported: false)
}
let session = WCSession.default
return WatchStatus(
isSupported: true,
isPaired: session.isPaired,
isWatchAppInstalled: session.isWatchAppInstalled,
isReachable: session.isReachable,
activationState: session.activationState
)
}
}
private struct WatchStatusView: View {
let status: WatchStatus
var body: some View {
if status.isSupported {
LabeledContent("Paired", value: status.isPaired ? "Yes" : "No")
LabeledContent("Watch App Installed", value: status.isWatchAppInstalled ? "Yes" : "No")
LabeledContent("Reachable", value: status.isReachable ? "Yes" : "No")
LabeledContent("WCSession", value: activationLabel(for: status.activationState))
} else {
Text("WatchConnectivity not supported on this device.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func activationLabel(for state: WCSessionActivationState?) -> String {
switch state {
case .activated:
return "Activated"
case .inactive:
return "Inactive"
case .notActivated:
return "Not Activated"
case .none:
return "Unavailable"
@unknown default:
return "Unknown"
}
}
}
private struct PlatformAvailabilityDescriptionView: View {
let availability: PlatformAvailability
var body: some View {
Text(descriptionText)
.font(.caption)
.foregroundStyle(.secondary)
}
private var descriptionText: String {
switch availability {
case .all:
return "Data accessible on both iPhone and Apple Watch"
case .phoneOnly:
return "Data only accessible on iPhone. Watch access throws error."
case .watchOnly:
return "Data only accessible on Watch. iPhone access throws error."
case .phoneWithWatchSync:
return "Stored on iPhone, synced to Watch via WatchConnectivity"
}
}
}
private struct SyncPolicyDescriptionView: View {
let syncPolicy: SyncPolicy
var body: some View {
Text(descriptionText)
.font(.caption)
.foregroundStyle(.secondary)
}
private var descriptionText: String {
switch syncPolicy {
case .never:
return "Data stays local, never synced"
case .manual:
return "Sync triggered by an explicit save call"
case .automaticSmall:
return "Auto-sync if data ≤ 50KB, otherwise throws error"
}
}
}
private struct ExpectedOutcomeView: View {
let availability: PlatformAvailability
let syncPolicy: SyncPolicy
var body: some View {
Text(expectedText)
.font(.caption)
.foregroundStyle(Color.Status.info)
}
private var expectedText: String {
if availability == .phoneOnly {
return "Expected: Stored only on iPhone. Watch cannot read this key."
}
if availability == .watchOnly {
return "Expected: iPhone access fails. Watch can read this key."
}
if availability == .phoneWithWatchSync {
return syncPolicy == .never
? "Expected: Stored on iPhone only. Sync disabled."
: "Expected: Stored on iPhone and synced to Watch."
}
return syncPolicy == .never
? "Expected: Stored on both devices when written locally."
: "Expected: Stored and synced between iPhone and Watch."
}
}

View File

@ -2,4 +2,5 @@ import Foundation
public enum StorageKeyNames { public enum StorageKeyNames {
public static let userProfile = "user_profile.json" public static let userProfile = "user_profile.json"
public static let syncableSetting = "syncable_setting"
} }

View File

@ -0,0 +1,5 @@
import Foundation
public enum WatchSyncMessageKeys {
public static let requestSync = "request_sync"
}