Compare commits

...

3 Commits

14 changed files with 525 additions and 88 deletions

View File

@ -6,15 +6,36 @@ A sample iOS app demonstrating the LocalData package capabilities for secure, ty
This app provides interactive demos for all LocalData storage options:
| Tab | Demo | Storage Domain |
|-----|------|----------------|
| **Defaults** | Save/load/remove values | UserDefaults |
| Screen | Demo | Storage Domain |
|--------|------|----------------|
| **UserDefaults** | Save/load/remove values | UserDefaults |
| **Keychain** | Secure credentials with biometrics | Keychain |
| **Files** | User profiles with Codable models | File System |
| **Encrypted** | Encrypted logs (AES or ChaCha20) | Encrypted File System |
| **Sync** | Platform availability & sync policies | Multiple |
| **File Storage** | User profiles with Codable models | File System |
| **Encrypted Storage** | Encrypted logs (AES or ChaCha20) | Encrypted File System |
| **Platform Sync Lab** | Platform availability & sync policies | Multiple |
The project also includes a watchOS companion app target for watch-specific demos.
The watch app displays the synced user profile and the syncable setting from the Platform Sync Lab.
On iPhone launch and when the watch becomes available, the app re-sends any syncable keys so the watch updates without manual re-entry.
The watch app also requests a sync on launch when the iPhone is reachable.
## Watch Sync Handshake
This sample uses a launch-order-safe handshake so either app can start first:
1. **Watch app launches** → sends a `request_sync` message (or queues it if the iPhone is unreachable).
2. **iOS app receives the request** → replies with a snapshot of current syncable keys and updates `applicationContext`.
3. **Watch app applies the snapshot** → UI updates immediately.
This avoids requiring users to remember which app to open first.
### Where the Logic Lives
- iOS WCSession + handshake: `SecureStorageSample/SecureStorageSample/Services/WatchConnectivityService.swift`
- Bootstrap on launch: `SecureStorageSample/SecureStorageSample/SecureStorageSampleApp.swift`
- Sync policy UI lab: `SecureStorageSample/SecureStorageSample/Views/PlatformSyncDemo.swift`
- Watch WCSession + request: `SecureStorageSample/SecureStorageSample Watch App/Services/WatchConnectivityService.swift`
- Watch payload handlers: `SecureStorageSample/SecureStorageSample Watch App/Services/Handlers/`
## Requirements

View File

@ -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)
}
}

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

@ -4,7 +4,7 @@ A watchOS companion app demonstrating data synchronization with the iOS app usin
## Overview
This watch app receives `UserProfile` data synced from the paired iPhone via `WCSession.updateApplicationContext`. It does **not** use LocalData directly for storage—instead, it displays synced data in memory.
This watch app receives `UserProfile` data and the syncable setting from the paired iPhone via WatchConnectivity. It does **not** use LocalData directly for storage—instead, it displays synced data in memory.
## Architecture
@ -12,6 +12,8 @@ This watch app receives `UserProfile` data synced from the paired iPhone via `WC
SecureStorageSample Watch App/
├── ContentView.swift # Displays synced profile data
├── SecureStorageSampleApp.swift
├── Design/
│ └── WatchDesignConstants.swift
├── Protocols/
│ └── WatchDataHandling.swift # Protocol for payload handlers
├── State/
@ -20,15 +22,25 @@ SecureStorageSample Watch App/
├── WatchConnectivityService.swift
└── Handlers/
└── UserProfileWatchHandler.swift
└── SyncableSettingWatchHandler.swift
```
## Data Flow
1. **iOS app** calls `SyncHelper` when storing data with `syncPolicy: .automaticSmall` or `.manual`
2. `SyncHelper` sends data via `WCSession.updateApplicationContext`
1. **Watch app** requests a sync when it launches or becomes reachable
2. **iOS app** replies with a snapshot of syncable keys and updates `applicationContext`
3. **Watch app** receives context in `WatchConnectivityService`
4. The service dispatches each payload key to its registered `WatchDataHandling` handler
5. `UserProfileWatchHandler` decodes the profile and updates `WatchProfileStore`
5. Handlers decode values and update `WatchProfileStore`
## Launch-Order-Safe Sync
The watch app handles both cases:
- If the iPhone is reachable, it sends a `request_sync` message and applies the reply payload.
- If the iPhone is not reachable, it queues a request with `transferUserInfo` and shows a badge.
This ensures users do not need to launch the apps in a specific order.
## Adding New Sync Payloads

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 {
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)
}
}
}

View File

@ -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")
}
}
}

View File

@ -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
}

View File

@ -44,7 +44,7 @@ enum DemoDestination: Hashable, CaseIterable {
case .encrypted:
return "Encrypted Storage"
case .sync:
return "Platform & Sync"
return "Platform Sync Lab"
case .migration:
return "Migrations"
}

View File

@ -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
}

View File

@ -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()
}
}
}
}

View File

@ -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."
}
}

View File

@ -2,4 +2,5 @@ import Foundation
public enum StorageKeyNames {
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"
}