Update SecureStorageSample

This commit is contained in:
Matt Bruce 2026-01-17 12:18:05 -06:00
parent 4ad6cc3d6a
commit 2e437dac91
43 changed files with 137 additions and 10 deletions

View File

@ -8,6 +8,7 @@
import SwiftUI
import LocalData
/// Defines all demo destinations and provides display metadata for the list.
enum DemoDestination: Hashable, CaseIterable {
case userDefaults
case keychain
@ -16,6 +17,8 @@ enum DemoDestination: Hashable, CaseIterable {
case sync
case migration
/// Type-erased destination view for NavigationStack routing.
/// The enum keeps navigation data-driven and centralized.
var view: some View {
switch self {
case .userDefaults:
@ -68,6 +71,7 @@ enum DemoDestination: Hashable, CaseIterable {
}
}
/// Root navigation list that links to each LocalData demo screen.
struct ContentView: View {
var body: some View {
NavigationStack {

View File

@ -1,5 +1,6 @@
import SwiftUI
/// Design tokens for spacing and common UI colors used in the sample.
enum Design {
enum Spacing {
static let xSmall: CGFloat = 4
@ -10,6 +11,7 @@ enum Design {
}
extension Color {
/// Semantic colors used for status messaging in demo screens.
enum Status {
static let success = Color.green
static let info = Color.blue
@ -17,6 +19,7 @@ extension Color {
static let error = Color.red
}
/// Semantic text colors for secondary labels.
enum Text {
static let secondary = Color.secondary
}

View File

@ -1,6 +1,7 @@
import Foundation
/// Simple credential model for keychain storage demo.
/// Simple credential model for the Keychain demo.
/// Using `Codable` keeps the storage path identical to more complex real-world credentials.
nonisolated
struct Credential: Codable, Sendable {
let username: String

View File

@ -1,5 +1,7 @@
import Foundation
/// Structured name model used in migration demos.
/// This replaces a legacy single-string name with distinct components.
nonisolated struct ProfileName: Codable, Sendable {
let firstName: String
let lastName: String

View File

@ -1,6 +1,8 @@
import Foundation
import LocalData
/// Identifiers for custom key material sources used by the sample.
/// LocalData uses these identifiers to look up registered providers.
nonisolated enum SampleKeyMaterialSources {
nonisolated static let external = KeyMaterialSource(id: "sample.external.key")
}

View File

@ -1,6 +1,7 @@
import Foundation
/// Location data model.
/// Lightweight location model for the Keychain demo.
/// It shows that LocalData can store structured data even in secure domains.
nonisolated
struct SampleLocationData: Codable, Sendable {
let lat: Double

View File

@ -1,5 +1,7 @@
import Foundation
/// Aggregated settings model produced by migration demos.
/// It combines multiple legacy settings into a single modern payload.
nonisolated struct UnifiedSettings: Codable, Sendable {
let notificationsEnabled: Bool
let theme: String

View File

@ -10,9 +10,13 @@ import LocalData
import SharedKit
@main
/// App entry point that centralizes LocalData configuration and storage key registration.
/// Keeping this bootstrap in one place makes the sample's storage behavior easy to audit.
struct SecureStorageSampleApp: App {
init() {
// Log derived identifiers up front so it's obvious which services the sample uses.
StorageServiceIdentifiers.logConfiguration()
// Spin up WCSession early so background sync opportunities aren't missed.
_ = WatchConnectivityService.shared
Task {
// 1. Global Encryption Configuration
@ -47,15 +51,18 @@ struct SecureStorageSampleApp: App {
)
await StorageRouter.shared.updateStorageConfiguration(storageConfig)
// Register keys once so LocalData can validate and migrate them consistently.
do {
try await StorageRouter.shared.registerCatalog(AppStorageCatalog(), migrateImmediately: true)
} catch {
assertionFailure("Storage catalog registration failed: \(error)")
}
// Provide external key material for the encrypted storage demo.
await StorageRouter.shared.registerKeyMaterialProvider(
ExternalKeyMaterialProvider(),
for: SampleKeyMaterialSources.external
)
// If a watch is paired, send the current syncable payloads on launch.
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
#if DEBUG

View File

@ -3,8 +3,11 @@ import LocalData
import SharedKit
/// Catalog of all storage keys used by the sample app.
/// Registering a catalog allows LocalData to audit keys and run migrations up front.
struct AppStorageCatalog: StorageKeyCatalog {
var allKeys: [AnyStorageKey] {
// Order here is purely for readability in audit reports.
[
.key(.appVersion),
.key(.userPreferences),

View File

@ -2,18 +2,22 @@ import CryptoKit
import Foundation
import LocalData
/// Supplies external key material for the encrypted storage demo.
/// The provider persists the generated key material so encryption remains stable across launches.
nonisolated
struct ExternalKeyMaterialProvider: KeyMaterialProviding {
private enum Constants {
static let keyLength = 32
}
/// Returns a stable 256-bit key, generating and persisting it on first use.
func keyMaterial(for keyName: String) async throws -> Data {
let key = StorageKey.externalKeyMaterial
if let existing = try? await StorageRouter.shared.get(key) {
return existing
}
// CryptoKit ensures the material is cryptographically random.
let symmetricKey = SymmetricKey(size: .bits256)
let material = symmetricKey.withUnsafeBytes { Data($0) }
guard material.count == Constants.keyLength else {

View File

@ -4,6 +4,8 @@ import SharedKit
import WatchConnectivity
@MainActor
/// iOS-side WatchConnectivity bridge that keeps the watch app in sync with LocalData.
/// This class focuses on coordination and delegates data payload creation to LocalData.
final class WatchConnectivityService: NSObject, WCSessionDelegate {
static let shared = WatchConnectivityService()
@ -12,6 +14,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
activateIfSupported()
}
/// Activates WCSession only on supported devices (no-ops on unsupported hardware).
private func activateIfSupported() {
guard WCSession.isSupported() else { return }
let session = WCSession.default
@ -29,6 +32,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
} else {
Logger.debug("iOS WCSession activated with state: \(activationState.rawValue)")
}
// Try to sync any previously registered keys as soon as WCSession is ready.
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
@ -44,6 +48,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
func sessionWatchStateDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession watch state changed: paired=\(session.isPaired) installed=\(session.isWatchAppInstalled)")
// A watch install or pairing change is a good time to re-send syncable keys.
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
@ -51,12 +56,14 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
func sessionReachabilityDidChange(_ session: WCSession) {
Logger.debug("iOS WCSession reachability changed: reachable=\(session.isReachable)")
// When reachability flips, attempt to push any pending sync payloads.
Task {
await StorageRouter.shared.syncRegisteredKeysIfNeeded()
}
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
// Watch may request a sync without needing a reply (fire-and-forget message).
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request")
Task {
@ -74,6 +81,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
didReceiveMessage message: [String: Any],
replyHandler: @escaping ([String: Any]) -> Void
) {
// Reply-based handshake: watch expects a payload immediately if reachable.
if let request = message[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received watch sync request (reply)")
Task {
@ -84,6 +92,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
}
}
/// Attempts to build a snapshot payload, retrying briefly in case keys are still initializing.
private func buildSyncReplyPayload() async -> [String: Any] {
let maxAttempts = 3
for attempt in 1...maxAttempts {
@ -104,6 +113,7 @@ final class WatchConnectivityService: NSObject, WCSessionDelegate {
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
// transferUserInfo can deliver requests when the watch was previously unreachable.
if let request = userInfo[WatchSyncMessageKeys.requestSync] as? Bool, request {
Logger.debug("iOS received queued watch sync request")
Task {

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData
import SharedKit
/// App Group UserDefaults key used to demonstrate shared preferences between targets.
extension StorageKey where Value == String {
/// Stores a shared setting in App Group UserDefaults.
/// - Domain: App Group UserDefaults

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData
import SharedKit
/// App Group file key for sharing a user profile across app targets.
extension StorageKey where Value == UserProfile {
/// Stores a shared user profile in the App Group container.
/// - Domain: App Group File System
@ -21,6 +22,7 @@ extension StorageKey where Value == UserProfile {
syncPolicy: .never
)
/// Creates a version of the key for a different App Group directory.
nonisolated static func appGroupUserProfileKey(
directory: FileDirectory = .documents
) -> StorageKey {

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData
import SharedKit
/// App Group UserDefaults key for a generic preferences dictionary.
extension StorageKey where Value == [String: AnyCodable] {
/// Stores user preferences in App Group UserDefaults.
/// - Domain: App Group UserDefaults

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// Encrypted file system key that pulls key material from a registered provider.
extension StorageKey where Value == [String] {
/// Stores session logs with encryption using external key material.
nonisolated static let externalSessionLogs = StorageKey(

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// Encrypted file system key for long-lived private notes in Documents.
extension StorageKey where Value == String {
/// Stores private notes with encryption.
nonisolated static let privateNotes = StorageKey(

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// Encrypted file system key for log data with configurable key derivation.
extension StorageKey where Value == [String] {
/// Stores session logs with full encryption.
/// Configurable PBKDF2 iterations.
@ -15,6 +16,7 @@ extension StorageKey where Value == [String] {
syncPolicy: .never
)
/// Builds a variant with a custom PBKDF2 iteration count for demo purposes.
nonisolated static func sessionLogsKey(iterations: Int) -> StorageKey {
StorageKey(
name: "session_logs.json",

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// File system key for non-critical cache data stored in Caches.
extension StorageKey where Value == Data {
/// Stores cached data files.
nonisolated static let cachedData = StorageKey(

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// File system key that demonstrates custom serializer usage.
extension StorageKey where Value == String {
/// Example using custom serializer for specialized encoding.
nonisolated static let customEncoded = StorageKey(

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// File system key that persists a property list export.
extension StorageKey where Value == [String: AnyCodable] {
/// Stores settings as property list.
nonisolated static let settingsPlist = StorageKey(

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData
import SharedKit
/// File system key for the shared UserProfile payload.
extension StorageKey where Value == UserProfile {
/// Stores user profile as JSON file in documents.
nonisolated static let userProfileFile = StorageKey(
@ -15,6 +16,7 @@ extension StorageKey where Value == UserProfile {
syncPolicy: .automaticSmall
)
/// Builds a profile key for an alternate file directory.
nonisolated static func userProfileFileKey(directory: FileDirectory = .documents) -> StorageKey {
StorageKey(
name: UserProfile.storageKeyName,

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData
import SharedKit
/// Keychain key for short-lived API tokens that should not sync off-device.
extension StorageKey where Value == String {
/// Stores API token in keychain.
nonisolated static let apiToken = StorageKey(

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData
import SharedKit
/// Keychain key used for credentials with optional access control variations.
extension StorageKey where Value == Credential {
/// Stores user credentials securely in keychain.
/// Configurable accessibility and access control.
@ -16,6 +17,7 @@ extension StorageKey where Value == Credential {
syncPolicy: .never
)
/// Builds a key with custom Keychain accessibility or access control options.
nonisolated static func credentialsKey(
accessibility: KeychainAccessibility = .afterFirstUnlock,
accessControl: KeychainAccessControl? = nil

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData
import SharedKit
/// Keychain key for externally provided key material used in encryption demos.
extension StorageKey where Value == Data {
/// Stores external key material used for encryption policies.
nonisolated static let externalKeyMaterial = StorageKey(

View File

@ -2,6 +2,7 @@ import Foundation
import LocalData
import SharedKit
/// Keychain key for sensitive location data with user presence required.
extension StorageKey where Value == SampleLocationData {
/// Stores sensitive location data in keychain with biometric protection.
nonisolated static let lastLocation = StorageKey(

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// Legacy notification toggle stored in UserDefaults.
extension StorageKey where Value == Bool {
nonisolated static let legacyNotificationSetting = StorageKey(
name: "legacy_notification_setting",
@ -14,6 +15,7 @@ extension StorageKey where Value == Bool {
)
}
/// Legacy theme preference stored separately from notifications.
extension StorageKey where Value == String {
nonisolated static let legacyThemeSetting = StorageKey(
name: "legacy_theme_setting",
@ -27,8 +29,9 @@ extension StorageKey where Value == String {
)
}
/// Modern unified settings created by aggregating multiple legacy keys.
extension StorageKey where Value == UnifiedSettings {
nonisolated static let modernUnifiedSettings = StorageKey(
name: "modern_unified_settings",
domain: .fileSystem(directory: .documents),
@ -48,6 +51,7 @@ extension StorageKey where Value == UnifiedSettings {
destinationKey: key,
sourceKeys: sources
) { sources in
// Merge legacy values into a single strongly-typed settings model.
var notificationsEnabled = false
var theme = "system"

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// Keys that demonstrate conditional migrations based on app version.
extension StorageKey where Value == String {
nonisolated static let legacyAppMode = StorageKey(
name: "legacy_app_mode",

View File

@ -1,8 +1,9 @@
import Foundation
import LocalData
/// Keys that demonstrate a simple legacy-to-modern migration path.
extension StorageKey where Value == String {
/// The legacy key where data starts (in UserDefaults)
/// The legacy key where data starts (in UserDefaults).
nonisolated static let legacyMigrationSource = StorageKey(
name: "legacy_user_id",
domain: .userDefaults(suite: nil),
@ -14,7 +15,7 @@ extension StorageKey where Value == String {
syncPolicy: .never
)
/// The modern key where data should end up (in Keychain)
/// The modern key where data should end up (in Keychain).
nonisolated static let modernMigrationDestination = StorageKey(
name: "secure_user_id",
domain: .keychain(service: "com.mbrucedogs.securestorage"),
@ -28,6 +29,7 @@ extension StorageKey where Value == String {
availability: .all,
syncPolicy: .never,
migration: { key in
// Simple "move on first read" migration for legacy identifiers.
AnyStorageMigration(
SimpleLegacyMigration(
destinationKey: key,

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// Legacy string payload that will be transformed into a structured model.
extension StorageKey where Value == String {
nonisolated static let legacyProfileName = StorageKey(
name: "legacy_profile_name",
@ -14,6 +15,7 @@ extension StorageKey where Value == String {
)
}
/// Modern structured payload produced by transforming migration.
extension StorageKey where Value == ProfileName {
nonisolated static let modernProfileName = StorageKey(
name: "modern_profile_name",
@ -34,6 +36,7 @@ extension StorageKey where Value == ProfileName {
destinationKey: key,
sourceKey: sourceKey
) { value in
// Split "First Last" into a structured name for safer usage.
let parts = value.split(separator: " ", maxSplits: 1).map(String.init)
let firstName = parts.first ?? ""
let lastName = parts.count > 1 ? parts[1] : ""

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// UserDefaults key that highlights platform availability and sync policy options.
extension StorageKey where Value == String {
/// Syncable setting with configurable platform and sync policy.
/// Grouped under Platform to highlight availability/sync behavior.
@ -15,6 +16,7 @@ extension StorageKey where Value == String {
syncPolicy: .never
)
/// Builds a variant to demonstrate different availability and sync policies.
nonisolated static func syncableSettingKey(
availability: PlatformAvailability = .all,
syncPolicy: SyncPolicy = .never

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// UserDefaults key for watch-only preferences, used to demonstrate availability constraints.
extension StorageKey where Value == Bool {
/// Watch-only setting for vibration.
/// Grouped under Platform to highlight watch-only availability.

View File

@ -1,6 +1,7 @@
import Foundation
import LocalData
/// UserDefaults key used in the demo and as a lightweight migration flag.
extension StorageKey where Value == String {
/// Stores the app version in standard UserDefaults.
/// - Domain: UserDefaults (standard)

View File

@ -2,6 +2,7 @@ import SwiftUI
import LocalData
@MainActor
/// Demonstrates aggregating multiple legacy keys into a UnifiedSettings model.
struct AggregatingMigrationDemo: View {
@State private var notificationsEnabled = false
@State private var theme = ""
@ -58,6 +59,7 @@ struct AggregatingMigrationDemo: View {
.navigationBarTitleDisplayMode(.inline)
}
/// Seeds the legacy keys to simulate pre-migration settings.
private func saveToLegacy() {
isLoading = true
Task {
@ -78,6 +80,7 @@ struct AggregatingMigrationDemo: View {
}
}
/// Loads the modern unified key, triggering the aggregation migration.
private func loadFromModern() {
isLoading = true
statusMessage = "Loading unified settings..."
@ -95,6 +98,7 @@ struct AggregatingMigrationDemo: View {
}
}
/// Formats the aggregated settings for display.
private func format(_ value: UnifiedSettings) -> String {
let notificationsText = value.notificationsEnabled ? "On" : "Off"
let themeText = value.theme.isEmpty ? "system" : value.theme

View File

@ -2,6 +2,7 @@ import SwiftUI
import LocalData
@MainActor
/// Demonstrates a migration that only runs when app version criteria are met.
struct ConditionalMigrationDemo: View {
@State private var legacyValue = ""
@State private var modernValue = ""
@ -59,6 +60,7 @@ struct ConditionalMigrationDemo: View {
.navigationBarTitleDisplayMode(.inline)
}
/// Seeds legacy data that will migrate when the version condition passes.
private func saveToLegacy() {
isLoading = true
Task {
@ -73,6 +75,7 @@ struct ConditionalMigrationDemo: View {
}
}
/// Loads the modern key to trigger the conditional migration.
private func loadFromModern() {
isLoading = true
statusMessage = "Loading modern mode..."

View File

@ -9,6 +9,7 @@ import SwiftUI
import LocalData
@MainActor
/// Demonstrates encrypted file storage with PBKDF2 and external key material options.
struct EncryptedStorageDemo: View {
@State private var logEntry = ""
@State private var storedLogs: [String] = []
@ -127,6 +128,7 @@ struct EncryptedStorageDemo: View {
}
}
/// Appends an encrypted log entry using the selected key derivation mode.
private func addLogEntry() {
isLoading = true
Task {
@ -152,6 +154,7 @@ struct EncryptedStorageDemo: View {
}
}
/// Decrypts and loads log entries into memory for display.
private func loadLogs() {
isLoading = true
Task {
@ -174,6 +177,7 @@ struct EncryptedStorageDemo: View {
}
}
/// Clears the encrypted log file for a clean slate.
private func clearLogs() {
isLoading = true
Task {
@ -194,6 +198,7 @@ struct EncryptedStorageDemo: View {
}
}
/// Loads existing logs, appends the new entry, and returns the updated payload.
private func updatedLogs(for key: StorageKey<[String]>) async throws -> [String] {
var logs: [String]
do {
@ -214,6 +219,7 @@ struct EncryptedStorageDemo: View {
}
}
/// Visual reminder that PBKDF2 iterations must remain stable to decrypt existing data.
private struct IterationWarningView: View {
var body: some View {
Text("PBKDF2 iterations must match the value used during encryption. Changing this after saving will prevent decryption.")

View File

@ -10,6 +10,7 @@ import LocalData
import SharedKit
@MainActor
/// Demonstrates file storage in Documents/Caches plus App Group file sharing.
struct FileSystemDemo: View {
@State private var profileName = ""
@State private var profileEmail = ""
@ -168,6 +169,7 @@ struct FileSystemDemo: View {
}
}
/// Saves a UserProfile to the selected file system directory.
private func saveProfile() {
isLoading = true
Task {
@ -188,6 +190,7 @@ struct FileSystemDemo: View {
}
}
/// Loads a UserProfile from the selected directory and updates the form.
private func loadProfile() {
isLoading = true
Task {
@ -212,6 +215,7 @@ struct FileSystemDemo: View {
}
}
/// Deletes the profile file from the selected directory.
private func deleteProfile() {
isLoading = true
Task {
@ -227,6 +231,7 @@ struct FileSystemDemo: View {
}
}
/// Persists a UserProfile into the App Group container.
private func saveAppGroupProfile() {
isLoading = true
Task {
@ -247,6 +252,7 @@ struct FileSystemDemo: View {
}
}
/// Loads a UserProfile from the App Group container.
private func loadAppGroupProfile() {
isLoading = true
Task {
@ -271,6 +277,7 @@ struct FileSystemDemo: View {
}
}
/// Deletes the App Group profile file to demonstrate shared cleanup.
private func deleteAppGroupProfile() {
isLoading = true
Task {

View File

@ -9,6 +9,7 @@ import SwiftUI
import LocalData
@MainActor
/// Demonstrates Keychain storage with configurable accessibility and access control.
struct KeychainDemo: View {
@State private var username = ""
@State private var password = ""
@ -113,6 +114,7 @@ struct KeychainDemo: View {
}
}
/// Stores credentials using the currently selected Keychain policies.
private func saveCredentials() {
isLoading = true
Task {
@ -131,6 +133,7 @@ struct KeychainDemo: View {
}
}
/// Loads credentials using the currently selected Keychain policies.
private func loadCredentials() {
isLoading = true
Task {
@ -157,6 +160,7 @@ struct KeychainDemo: View {
}
}
/// Removes the stored credentials from the Keychain.
private func deleteCredentials() {
isLoading = true
Task {

View File

@ -2,6 +2,7 @@ import SwiftUI
import LocalData
@MainActor
/// Demonstrates a simple legacy key migration from UserDefaults to Keychain.
struct MigrationDemo: View {
@State private var legacyValue = ""
@State private var modernValue = ""
@ -83,6 +84,7 @@ struct MigrationDemo: View {
// MARK: - Actions
/// Seeds the legacy key to simulate pre-migration data.
private func saveToLegacy() {
isLoading = true
Task {
@ -97,6 +99,7 @@ struct MigrationDemo: View {
}
}
/// Loads the modern key, triggering the configured migration if needed.
private func loadFromModern() {
isLoading = true
statusMessage = "Retrieving from Modern..."
@ -115,6 +118,7 @@ struct MigrationDemo: View {
}
}
/// Forces a migration sweep to drain legacy data even if the modern key exists.
private func runManualMigration() {
isLoading = true
statusMessage = "Running manual migration..."
@ -132,6 +136,7 @@ struct MigrationDemo: View {
}
}
/// Checks whether the legacy key still exists after migration.
private func checkLegacyExists() {
isLoading = true
Task {

View File

@ -1,5 +1,6 @@
import SwiftUI
/// Entry point for the migration demos, grouped by strategy.
struct MigrationHubView: View {
var body: some View {
Form {

View File

@ -10,6 +10,7 @@ import LocalData
import WatchConnectivity
@MainActor
/// Demonstrates platform availability and Watch sync policies for a single key.
struct PlatformSyncDemo: View {
@State private var settingValue = ""
@State private var storedValue = ""
@ -137,6 +138,7 @@ struct PlatformSyncDemo: View {
}
}
/// Saves the value using the current availability and sync policy selection.
private func saveValue() {
isLoading = true
Task {
@ -157,6 +159,7 @@ struct PlatformSyncDemo: View {
}
}
/// Loads the value using the current availability and sync policy selection.
private func loadValue() {
isLoading = true
Task {
@ -180,6 +183,7 @@ struct PlatformSyncDemo: View {
}
}
/// Attempts a read to show the platform access error when applicable.
private func testPlatformError() {
isLoading = true
Task {
@ -202,6 +206,7 @@ struct PlatformSyncDemo: View {
}
}
/// Refreshes the WCSession status shown in the UI.
private func refreshWatchStatus() {
watchStatus = WatchStatus.current()
}
@ -209,6 +214,7 @@ struct PlatformSyncDemo: View {
// MARK: - Display Names
/// UI-friendly names for platform availability options.
extension PlatformAvailability {
var displayName: String {
switch self {
@ -220,6 +226,7 @@ extension PlatformAvailability {
}
}
/// UI-friendly names for sync policies.
extension SyncPolicy {
var displayName: String {
switch self {
@ -236,6 +243,7 @@ extension SyncPolicy {
}
}
/// Snapshot of current WatchConnectivity state for display purposes.
private struct WatchStatus: Equatable {
let isSupported: Bool
let isPaired: Bool
@ -273,6 +281,7 @@ private struct WatchStatus: Equatable {
}
}
/// Read-only UI for displaying WatchConnectivity state.
private struct WatchStatusView: View {
let status: WatchStatus
@ -305,6 +314,7 @@ private struct WatchStatusView: View {
}
}
/// Explanatory text for each platform availability option.
private struct PlatformAvailabilityDescriptionView: View {
let availability: PlatformAvailability
@ -328,6 +338,7 @@ private struct PlatformAvailabilityDescriptionView: View {
}
}
/// Explanatory text for each sync policy option.
private struct SyncPolicyDescriptionView: View {
let syncPolicy: SyncPolicy
@ -349,6 +360,7 @@ private struct SyncPolicyDescriptionView: View {
}
}
/// Predicts the expected behavior given the selected availability and sync policy.
private struct ExpectedOutcomeView: View {
let availability: PlatformAvailability
let syncPolicy: SyncPolicy

View File

@ -2,6 +2,7 @@ import SwiftUI
import LocalData
@MainActor
/// Demonstrates transforming a legacy string into a structured ProfileName.
struct TransformingMigrationDemo: View {
@State private var legacyValue = ""
@State private var modernValue = ""
@ -59,6 +60,7 @@ struct TransformingMigrationDemo: View {
.navigationBarTitleDisplayMode(.inline)
}
/// Seeds the legacy string value to simulate pre-migration data.
private func saveToLegacy() {
isLoading = true
Task {
@ -73,6 +75,7 @@ struct TransformingMigrationDemo: View {
}
}
/// Loads the modern key, triggering the transforming migration.
private func loadFromModern() {
isLoading = true
statusMessage = "Loading modern profile..."
@ -91,6 +94,7 @@ struct TransformingMigrationDemo: View {
}
}
/// Formats the migrated name for display in the demo UI.
private func format(_ value: ProfileName) -> String {
let trimmedFirst = value.firstName.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedLast = value.lastName.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@ -9,6 +9,7 @@ import SwiftUI
import LocalData
@MainActor
/// Demonstrates typed UserDefaults keys, including App Group sharing.
struct UserDefaultsDemo: View {
@State private var inputText = ""
@State private var storedValue = ""
@ -136,6 +137,7 @@ struct UserDefaultsDemo: View {
}
}
/// Persists the current input value with the standard UserDefaults key.
private func saveValue() {
isLoading = true
Task {
@ -150,6 +152,7 @@ struct UserDefaultsDemo: View {
}
}
/// Loads the standard UserDefaults value and reflects it in the UI.
private func loadValue() {
isLoading = true
Task {
@ -169,6 +172,7 @@ struct UserDefaultsDemo: View {
}
}
/// Removes the standard UserDefaults value to reset the demo.
private func removeValue() {
isLoading = true
Task {
@ -184,6 +188,7 @@ struct UserDefaultsDemo: View {
}
}
/// Saves the input value into the App Group UserDefaults container.
private func saveAppGroupValue() {
isLoading = true
Task {
@ -198,6 +203,7 @@ struct UserDefaultsDemo: View {
}
}
/// Loads the App Group value to demonstrate cross-target sharing.
private func loadAppGroupValue() {
isLoading = true
Task {
@ -217,6 +223,7 @@ struct UserDefaultsDemo: View {
}
}
/// Clears the App Group value to demonstrate cleanup behavior.
private func removeAppGroupValue() {
isLoading = true
Task {

View File

@ -1,22 +1,24 @@
import Foundation
// Example shared models for Watch-optimized data
/// Example shared models that demonstrate creating Watch-optimized payloads.
/// The goal is to minimize what needs to traverse WatchConnectivity.
struct Workout: Codable {
let date: Date
// Add other properties as needed
}
/// Preferences included in the full profile; used to derive a slimmer watch payload.
struct Preferences: Codable {
let isPremium: Bool
let appearance: Appearance
}
/// Display preferences for the demo data.
enum Appearance: Codable {
case light, dark
}
// Shared model (iOS + watchOS)
/// Shared full-fidelity profile used on iOS, with a computed watch-friendly version.
struct FullUserProfile: Codable {
let id: UUID
let fullName: String
@ -25,7 +27,7 @@ struct FullUserProfile: Codable {
let allWorkouts: [Workout] // potentially huge
let preferences: Preferences
// Lightweight Watch version computed
/// Lightweight Watch version computed from the full profile.
var watchVersion: WatchUserProfile {
WatchUserProfile(
id: id,
@ -39,6 +41,7 @@ struct FullUserProfile: Codable {
}
}
/// Slimmed-down payload intended for watch display only.
struct WatchUserProfile: Codable, Sendable {
let id: UUID
let displayName: String
@ -54,7 +57,7 @@ struct WatchUserProfile: Codable, Sendable {
// let watchData = try JSONEncoder().encode(profile.watchVersion)
// WCSession.default.updateApplicationContext(["profile": watchData])
// Alternative: WatchRepresentable protocol
/// Optional protocol to standardize access to a watch-friendly payload.
public protocol WatchRepresentable {
associatedtype WatchVersion: Codable, Sendable
var watchVersion: WatchVersion { get }