Add PhoneConnectivityBadge, ProfileSectionView, SyncableSettingSectionView, StatusMessageView (+24 more)
This commit is contained in:
parent
d21fbd577f
commit
f0823a22c5
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import LocalData
|
import LocalData
|
||||||
|
import WatchConnectivity
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct PlatformSyncDemo: View {
|
struct PlatformSyncDemo: View {
|
||||||
@ -16,8 +17,9 @@ 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 {
|
||||||
Form {
|
Form {
|
||||||
Section {
|
Section {
|
||||||
@ -25,7 +27,11 @@ struct PlatformSyncDemo: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.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)
|
||||||
@ -34,10 +40,10 @@ struct PlatformSyncDemo: View {
|
|||||||
Text("Phone w/ Watch Sync").tag(PlatformAvailability.phoneWithWatchSync)
|
Text("Phone w/ Watch Sync").tag(PlatformAvailability.phoneWithWatchSync)
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
platformDescription
|
PlatformAvailabilityDescriptionView(availability: selectedPlatform)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Sync Policy") {
|
Section("Sync Policy") {
|
||||||
Picker("Sync Behavior", selection: $selectedSync) {
|
Picker("Sync Behavior", selection: $selectedSync) {
|
||||||
Text("Never").tag(SyncPolicy.never)
|
Text("Never").tag(SyncPolicy.never)
|
||||||
@ -45,15 +51,15 @@ struct PlatformSyncDemo: View {
|
|||||||
Text("Automatic (Small Data)").tag(SyncPolicy.automaticSmall)
|
Text("Automatic (Small Data)").tag(SyncPolicy.automaticSmall)
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
syncDescription
|
SyncPolicyDescriptionView(syncPolicy: selectedSync)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Test Data") {
|
Section("Test Data") {
|
||||||
TextField("Enter a value to store", text: $settingValue)
|
TextField("Enter a value to store", text: $settingValue)
|
||||||
.focused($isFieldFocused)
|
.focused($isFieldFocused)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Actions") {
|
Section("Actions") {
|
||||||
Button(action: saveValue) {
|
Button(action: saveValue) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -62,7 +68,7 @@ struct PlatformSyncDemo: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(settingValue.isEmpty || isLoading)
|
.disabled(settingValue.isEmpty || isLoading)
|
||||||
|
|
||||||
Button(action: loadValue) {
|
Button(action: loadValue) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "icloud.and.arrow.down")
|
Image(systemName: "icloud.and.arrow.down")
|
||||||
@ -70,7 +76,7 @@ struct PlatformSyncDemo: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
|
|
||||||
Button(action: testPlatformError) {
|
Button(action: testPlatformError) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
@ -80,30 +86,46 @@ struct PlatformSyncDemo: View {
|
|||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.disabled(isLoading)
|
.disabled(isLoading)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !storedValue.isEmpty {
|
if !storedValue.isEmpty {
|
||||||
Section("Retrieved Value") {
|
Section("Retrieved Value") {
|
||||||
Text(storedValue)
|
Text(storedValue)
|
||||||
.font(.system(.body, design: .monospaced))
|
.font(.system(.body, design: .monospaced))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !statusMessage.isEmpty {
|
if !statusMessage.isEmpty {
|
||||||
Section {
|
Section {
|
||||||
Text(statusMessage)
|
Text(statusMessage)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(statusMessage.contains("Error") ? .red :
|
.foregroundStyle(statusMessage.contains("Error") ? .red :
|
||||||
statusMessage.contains("⚠") ? .orange : .green)
|
statusMessage.contains("⚠") ? .orange : .green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
@ -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() {
|
private func saveValue() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
Task {
|
Task {
|
||||||
@ -165,14 +147,15 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadValue() {
|
private func loadValue() {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
Task {
|
Task {
|
||||||
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum WatchSyncMessageKeys {
|
||||||
|
public static let requestSync = "request_sync"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user