Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
b96b42b726
commit
9cd1652727
@ -12,8 +12,53 @@ struct ContentView: View {
|
||||
@State private var store = WatchProfileStore.shared
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if let profile = store.profile {
|
||||
VStack(alignment: .leading) {
|
||||
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)
|
||||
.bold()
|
||||
Text(profile.email)
|
||||
@ -30,17 +75,43 @@ struct ContentView: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !store.statusMessage.isEmpty {
|
||||
Text(store.statusMessage)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
private struct SyncableSettingSectionView: View {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,8 +20,10 @@ final class UserProfileWatchHandler: WatchDataHandling {
|
||||
do {
|
||||
let profile = try decoder.decode(UserProfile.self, from: data)
|
||||
store.setProfile(profile)
|
||||
Logger.debug("Watch synced user profile")
|
||||
} catch {
|
||||
store.setStatus("Failed to decode profile")
|
||||
Logger.error("Watch failed to decode user profile", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import SharedKit
|
||||
import WatchConnectivity
|
||||
|
||||
@MainActor
|
||||
@ -19,6 +20,9 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
private func registerDefaultHandlers() {
|
||||
let profileHandler = UserProfileWatchHandler(store: store)
|
||||
registerHandler(profileHandler)
|
||||
|
||||
let syncableSettingHandler = SyncableSettingWatchHandler(store: store)
|
||||
registerHandler(syncableSettingHandler)
|
||||
}
|
||||
|
||||
func registerHandler(_ handler: WatchDataHandling) {
|
||||
@ -37,10 +41,15 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
|
||||
private func loadCurrentContext() {
|
||||
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]) {
|
||||
let keys = context.keys.sorted().joined(separator: ", ")
|
||||
Logger.debug("Watch handling context keys: [\(keys)]")
|
||||
for (key, handler) in handlers {
|
||||
guard let data = context[key] as? Data else { continue }
|
||||
handler.handle(data: data)
|
||||
@ -52,10 +61,49 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
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()
|
||||
requestSyncIfNeeded()
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
Logger.debug("Watch received application context with \(applicationContext.count) keys")
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,9 @@ final class WatchProfileStore {
|
||||
static let shared = WatchProfileStore()
|
||||
|
||||
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 init() {}
|
||||
@ -17,6 +20,16 @@ final class WatchProfileStore {
|
||||
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) {
|
||||
statusMessage = message
|
||||
}
|
||||
|
||||
@ -56,10 +56,12 @@ struct SecureStorageSampleApp: App {
|
||||
ExternalKeyMaterialProvider(),
|
||||
for: SampleKeyMaterialSources.external
|
||||
)
|
||||
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
|
||||
}
|
||||
#if DEBUG
|
||||
let report = StorageAuditReport.renderText(AppStorageCatalog())
|
||||
print(report)
|
||||
// Disabled to keep console focused on sync logs.
|
||||
// let report = StorageAuditReport.renderText(AppStorageCatalog())
|
||||
// print(report)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import Foundation
|
||||
import LocalData
|
||||
import SharedKit
|
||||
import WatchConnectivity
|
||||
|
||||
@MainActor
|
||||
@ -22,7 +24,14 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
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) {
|
||||
@ -32,4 +41,74 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import LocalData
|
||||
import WatchConnectivity
|
||||
|
||||
@MainActor
|
||||
struct PlatformSyncDemo: View {
|
||||
@ -16,8 +17,9 @@ struct PlatformSyncDemo: View {
|
||||
@State private var isLoading = false
|
||||
@State private var selectedPlatform: PlatformAvailability = .all
|
||||
@State private var selectedSync: SyncPolicy = .never
|
||||
@State private var watchStatus = WatchStatus.current()
|
||||
@FocusState private var isFieldFocused: Bool
|
||||
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
@ -25,7 +27,11 @@ struct PlatformSyncDemo: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
|
||||
Section("Watch Status") {
|
||||
WatchStatusView(status: watchStatus)
|
||||
}
|
||||
|
||||
Section("Platform Availability") {
|
||||
Picker("Available On", selection: $selectedPlatform) {
|
||||
Text("All (iPhone + Watch)").tag(PlatformAvailability.all)
|
||||
@ -34,10 +40,10 @@ struct PlatformSyncDemo: View {
|
||||
Text("Phone w/ Watch Sync").tag(PlatformAvailability.phoneWithWatchSync)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
platformDescription
|
||||
|
||||
PlatformAvailabilityDescriptionView(availability: selectedPlatform)
|
||||
}
|
||||
|
||||
|
||||
Section("Sync Policy") {
|
||||
Picker("Sync Behavior", selection: $selectedSync) {
|
||||
Text("Never").tag(SyncPolicy.never)
|
||||
@ -45,15 +51,15 @@ struct PlatformSyncDemo: View {
|
||||
Text("Automatic (Small Data)").tag(SyncPolicy.automaticSmall)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
|
||||
syncDescription
|
||||
|
||||
SyncPolicyDescriptionView(syncPolicy: selectedSync)
|
||||
}
|
||||
|
||||
|
||||
Section("Test Data") {
|
||||
TextField("Enter a value to store", text: $settingValue)
|
||||
.focused($isFieldFocused)
|
||||
}
|
||||
|
||||
|
||||
Section("Actions") {
|
||||
Button(action: saveValue) {
|
||||
HStack {
|
||||
@ -62,7 +68,7 @@ struct PlatformSyncDemo: View {
|
||||
}
|
||||
}
|
||||
.disabled(settingValue.isEmpty || isLoading)
|
||||
|
||||
|
||||
Button(action: loadValue) {
|
||||
HStack {
|
||||
Image(systemName: "icloud.and.arrow.down")
|
||||
@ -70,7 +76,7 @@ struct PlatformSyncDemo: View {
|
||||
}
|
||||
}
|
||||
.disabled(isLoading)
|
||||
|
||||
|
||||
Button(action: testPlatformError) {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
@ -80,30 +86,46 @@ struct PlatformSyncDemo: View {
|
||||
.foregroundStyle(.orange)
|
||||
.disabled(isLoading)
|
||||
}
|
||||
|
||||
|
||||
if !storedValue.isEmpty {
|
||||
Section("Retrieved Value") {
|
||||
Text(storedValue)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if !statusMessage.isEmpty {
|
||||
Section {
|
||||
Text(statusMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(statusMessage.contains("Error") ? .red :
|
||||
.foregroundStyle(statusMessage.contains("Error") ? .red :
|
||||
statusMessage.contains("⚠") ? .orange : .green)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Section("Expected Result") {
|
||||
ExpectedOutcomeView(
|
||||
availability: selectedPlatform,
|
||||
syncPolicy: selectedSync
|
||||
)
|
||||
}
|
||||
|
||||
Section("Current Configuration") {
|
||||
LabeledContent("Platform", value: selectedPlatform.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)
|
||||
.task {
|
||||
refreshWatchStatus()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
@ -113,47 +135,7 @@ 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() {
|
||||
isLoading = true
|
||||
Task {
|
||||
@ -165,14 +147,15 @@ struct PlatformSyncDemo: View {
|
||||
try await StorageRouter.shared.set(settingValue, for: key)
|
||||
statusMessage = "✓ Saved with \(selectedPlatform.displayName) availability and \(selectedSync.displayName) sync"
|
||||
} catch StorageError.dataTooLargeForSync {
|
||||
statusMessage = "Error: Data too large for automatic sync (max 100KB)"
|
||||
statusMessage = "Error: Data too large for automatic sync (max 50KB)"
|
||||
} catch {
|
||||
statusMessage = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
isLoading = false
|
||||
refreshWatchStatus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func loadValue() {
|
||||
isLoading = true
|
||||
Task {
|
||||
@ -183,7 +166,7 @@ struct PlatformSyncDemo: View {
|
||||
)
|
||||
let value = try await StorageRouter.shared.get(key)
|
||||
storedValue = value
|
||||
settingValue = value // Sync to field
|
||||
settingValue = value
|
||||
statusMessage = "✓ Loaded value"
|
||||
} catch StorageError.notFound {
|
||||
storedValue = ""
|
||||
@ -192,13 +175,13 @@ struct PlatformSyncDemo: View {
|
||||
statusMessage = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
isLoading = false
|
||||
refreshWatchStatus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func testPlatformError() {
|
||||
isLoading = true
|
||||
Task {
|
||||
// Try to access a watchOnly key from iPhone
|
||||
let key = StorageKeys.WatchVibrationKey()
|
||||
do {
|
||||
_ = try await StorageRouter.shared.get(key)
|
||||
@ -209,8 +192,13 @@ struct PlatformSyncDemo: View {
|
||||
statusMessage = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
isLoading = false
|
||||
refreshWatchStatus()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshWatchStatus() {
|
||||
watchStatus = WatchStatus.current()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Display Names
|
||||
@ -241,3 +229,147 @@ extension SyncPolicy {
|
||||
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."
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,4 +2,5 @@ import Foundation
|
||||
|
||||
public enum StorageKeyNames {
|
||||
public static let userProfile = "user_profile.json"
|
||||
public static let syncableSetting = "syncable_setting"
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
public enum WatchSyncMessageKeys {
|
||||
public static let requestSync = "request_sync"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user