diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index f7d19d1..8969e5b 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -36,6 +36,13 @@ reminders as a notification product, messaging, and a web client. ## Timeline +- 2026-08-23 — made the Significant Hobbies connection truthful: Kith now + distinguishes account authentication from successful Hub synchronization, + shows last success and durable waiting changes, explains local/iCloud/Hub + roles, and gives different recovery guidance for expired sign-in, offline, + and service failures. The change is verified in source and awaits its next + TestFlight build. + - 2026-08-23 — bumped the app build number to `1.0.0 (4)` so the already-landed constellation onboarding ships in its own unique internal TestFlight build. Build 3 predates the onboarding delivery (it was prepared in #14 before #16 @@ -89,8 +96,10 @@ reminders as a notification product, messaging, and a web client. - Searchable list fallback and reduced-motion static layout - Empty state and a `--ui-demo` fixture for tests and screenshots - Private CloudKit mirror on the personal team when iCloud is signed in -- Optional signed-in Cloudflare synchronization for people and interactions, +- Optional signed-in Hub synchronization for people and interactions, with a durable local outbox, foreground/manual sync, and no local-data import +- Truthful Hub connection state with last success, waiting-change count, and + actionable authentication/network/service recovery - Public factory landing at kith.significanthobbies.com ## Work queue diff --git a/ios/Kith.xcodeproj/project.pbxproj b/ios/Kith.xcodeproj/project.pbxproj index 08c404c..97cc915 100644 --- a/ios/Kith.xcodeproj/project.pbxproj +++ b/ios/Kith.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 09E8E56D1B58E87EC470F8DE /* KithCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 706248B78A28D30986A34B42 /* KithCore.framework */; }; + 0D21685BEDBF76C6AE714CEF /* PersonalSyncKit in Frameworks */ = {isa = PBXBuildFile; productRef = 06F16ED7DD7305671AC61A80 /* PersonalSyncKit */; }; 15835961CE7BABD27EA30295 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8E3415A5E88E90361208B65E /* CloudKit.framework */; }; 1D8DB7DB42B1154E40F56ED6 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = A662AC2BCD668AE688D25BB5 /* Persistence.swift */; }; 1D9712691351162D15E59B82 /* KithPlatformRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09DA028D29EAA834DF9E94A7 /* KithPlatformRecord.swift */; }; @@ -28,6 +29,7 @@ 9CD8FB3118B722E7027AF697 /* CloudStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A8B5ADE80477C15D5324428 /* CloudStore.swift */; }; A076BAE8EDA2DF34808A76E6 /* PersonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 283EE3AFA2ADACAC47EDC05F /* PersonViews.swift */; }; A3E58ADFB234FD1407023392 /* KithOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 416A75C69650AAD584FEE064 /* KithOnboardingView.swift */; }; + A4336E3A96ACF2FE47DA6C35 /* HubSyncIssueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A41324D637F7A736FAD6B80A /* HubSyncIssueTests.swift */; }; B11A9EBD171FF12E6F7B0045 /* KithUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B0BA41F320166AEA1728A2 /* KithUITests.swift */; }; F3C48A6CA9EC720182B123BC /* Design.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE4DDB782CAF5D8FD571DC3B /* Design.swift */; }; F680576C1F45EE52AFBA44DE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 2C466A859325D15A49C22248 /* PrivacyInfo.xcprivacy */; }; @@ -55,6 +57,13 @@ remoteGlobalIDString = FBF266D51143FD491CAAD363; remoteInfo = KithCore; }; + B1D830B797180E77DC3EAEFC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9551192361D22AAFCE735BA5 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BCC96A7E8083614AFEF97660; + remoteInfo = Kith; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -99,6 +108,7 @@ 7AA76B6FCE2A1BD2FDDE7D45 /* BubbleField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BubbleField.swift; sourceTree = ""; }; 8E3415A5E88E90361208B65E /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; 8FDEF1D744F055053CB588A9 /* KithCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KithCoreTests.swift; sourceTree = ""; }; + A41324D637F7A736FAD6B80A /* HubSyncIssueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubSyncIssueTests.swift; sourceTree = ""; }; A662AC2BCD668AE688D25BB5 /* Persistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persistence.swift; sourceTree = ""; }; A6982529B80DE3228F8C45F4 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; B10F1C159C3DA6714FD52E7A /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; @@ -107,10 +117,19 @@ BC05AFA81D366A2F3F3657C9 /* LogViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogViews.swift; sourceTree = ""; }; DC4EA45455B6FF74E4BCFC21 /* Domain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Domain.swift; sourceTree = ""; }; DE4DDB782CAF5D8FD571DC3B /* Design.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Design.swift; sourceTree = ""; }; + E01B4D3A257D3291B92EA865 /* KithAppTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = KithAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; F2AF9D313021EF3F3623DE6D /* KithConnectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KithConnectionView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 02C2804E9384EE13DC6C648A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0D21685BEDBF76C6AE714CEF /* PersonalSyncKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 72D5C2DB9641C125B2C3B696 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -139,6 +158,15 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 325F938B013767DA2643EE8C /* KithAppTests */ = { + isa = PBXGroup; + children = ( + A41324D637F7A736FAD6B80A /* HubSyncIssueTests.swift */, + ); + name = KithAppTests; + path = Tests/KithAppTests; + sourceTree = ""; + }; 397EE5C7F585780678D6EA3B /* Resources */ = { isa = PBXGroup; children = ( @@ -169,6 +197,7 @@ isa = PBXGroup; children = ( B5055DD36BBAD17F586CE183 /* Kith.app */, + E01B4D3A257D3291B92EA865 /* KithAppTests.xctest */, 706248B78A28D30986A34B42 /* KithCore.framework */, 093BCDF0FE2F82258D1A28E0 /* KithCoreTests.xctest */, 09C2DC255CA3B902918900FB /* KithUITests.xctest */, @@ -222,6 +251,7 @@ isa = PBXGroup; children = ( DA78E00117DC2C243A41A595 /* Kith */, + 325F938B013767DA2643EE8C /* KithAppTests */, C95DED6552F603708C492B95 /* KithCore */, C1BA66230AAF4E8FA087C9D8 /* KithCoreTests */, 9F523A73087559EADBF4E41D /* KithUITests */, @@ -293,6 +323,26 @@ productReference = B5055DD36BBAD17F586CE183 /* Kith.app */; productType = "com.apple.product-type.application"; }; + F427138298980ACAA39B1881 /* KithAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 31C77360362EBD87E22BEF91 /* Build configuration list for PBXNativeTarget "KithAppTests" */; + buildPhases = ( + 2516FF8BADB9B5EA74957D0A /* Sources */, + 02C2804E9384EE13DC6C648A /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + D31B901635A9AF506201C254 /* PBXTargetDependency */, + ); + name = KithAppTests; + packageProductDependencies = ( + 06F16ED7DD7305671AC61A80 /* PersonalSyncKit */, + ); + productName = KithAppTests; + productReference = E01B4D3A257D3291B92EA865 /* KithAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; FBF266D51143FD491CAAD363 /* KithCore */ = { isa = PBXNativeTarget; buildConfigurationList = 49C9377BCA132EF97A2917E4 /* Build configuration list for PBXNativeTarget "KithCore" */; @@ -333,6 +383,10 @@ DevelopmentTeam = 8F7LXHTJZR; ProvisioningStyle = Automatic; }; + F427138298980ACAA39B1881 = { + DevelopmentTeam = 8F7LXHTJZR; + ProvisioningStyle = Automatic; + }; FBF266D51143FD491CAAD363 = { DevelopmentTeam = 8F7LXHTJZR; ProvisioningStyle = Automatic; @@ -359,6 +413,7 @@ FBF266D51143FD491CAAD363 /* KithCore */, BCC96A7E8083614AFEF97660 /* Kith */, 5754601C9787A40EE381E8E8 /* KithCoreTests */, + F427138298980ACAA39B1881 /* KithAppTests */, 00D81C67312E33339B9E2E76 /* KithUITests */, ); }; @@ -377,6 +432,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 2516FF8BADB9B5EA74957D0A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A4336E3A96ACF2FE47DA6C35 /* HubSyncIssueTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 60738BEDC80337F5C7693E30 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -434,6 +497,11 @@ target = FBF266D51143FD491CAAD363 /* KithCore */; targetProxy = 3ADFFFC10B1726C575911AC8 /* PBXContainerItemProxy */; }; + D31B901635A9AF506201C254 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BCC96A7E8083614AFEF97660 /* Kith */; + targetProxy = B1D830B797180E77DC3EAEFC /* PBXContainerItemProxy */; + }; DF6867AC7DB039F012F2C38D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = BCC96A7E8083614AFEF97660 /* Kith */; @@ -544,6 +612,23 @@ }; name = Release; }; + 905830463A4E834774C06D9C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.significanthobbies.kith.app-tests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Kith.app/Kith"; + }; + name = Debug; + }; 95768831B27A65EBC4D6E96C /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -723,9 +808,35 @@ }; name = Release; }; + FE50467991B9CCF657129D40 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.significanthobbies.kith.app-tests"; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Kith.app/Kith"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 31C77360362EBD87E22BEF91 /* Build configuration list for PBXNativeTarget "KithAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 905830463A4E834774C06D9C /* Debug */, + FE50467991B9CCF657129D40 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 46B564538F5B7F86F2202233 /* Build configuration list for PBXNativeTarget "Kith" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -779,12 +890,17 @@ repositoryURL = "https://github.com/Significant-Hobbies/significanthobbies.git"; requirement = { kind = revision; - revision = 007cc255e75d291d10528ea9b123759d7e050be3; + revision = e350a86b2c255861f528d5a22bca1196d5a2898e; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 06F16ED7DD7305671AC61A80 /* PersonalSyncKit */ = { + isa = XCSwiftPackageProductDependency; + package = E68067ACC664B232138E2C16 /* XCRemoteSwiftPackageReference "significanthobbies" */; + productName = PersonalSyncKit; + }; 89477B67A2352008C58A6078 /* PersonalSyncKit */ = { isa = XCSwiftPackageProductDependency; package = E68067ACC664B232138E2C16 /* XCRemoteSwiftPackageReference "significanthobbies" */; diff --git a/ios/Kith.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Kith.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index fc7830a..b14b55a 100644 --- a/ios/Kith.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Kith.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "dd11a25c9804020e0d6b4e45260aaae743f6aaed446d4808a941cac8b4d5777d", + "originHash" : "6dab35764b25d650a4b6aed3a8feeffa5d8eef404ba9c0d5389a409f550270fc", "pins" : [ { "identity" : "significanthobbies", "kind" : "remoteSourceControl", "location" : "https://github.com/Significant-Hobbies/significanthobbies.git", "state" : { - "revision" : "007cc255e75d291d10528ea9b123759d7e050be3" + "revision" : "e350a86b2c255861f528d5a22bca1196d5a2898e" } } ], diff --git a/ios/Kith.xcodeproj/xcshareddata/xcschemes/Kith.xcscheme b/ios/Kith.xcodeproj/xcshareddata/xcschemes/Kith.xcscheme index 9809277..2ccb8e8 100644 --- a/ios/Kith.xcodeproj/xcshareddata/xcschemes/Kith.xcscheme +++ b/ios/Kith.xcodeproj/xcshareddata/xcschemes/Kith.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:Kith.xcodeproj"> + + + + + + + + diff --git a/ios/Sources/Kith/AppModel.swift b/ios/Sources/Kith/AppModel.swift index 643c7ef..b1b0acb 100644 --- a/ios/Sources/Kith/AppModel.swift +++ b/ios/Sources/Kith/AppModel.swift @@ -20,6 +20,10 @@ final class AppModel { var isShowingConnection = false var searchText = "" var message: String? + private(set) var isPlatformSyncing = false + private(set) var lastPlatformSyncAt: Date? + private(set) var platformPendingMutationCount = 0 + private(set) var platformSyncIssue: HubSyncIssue? private let store: KithStore private let cloud: KithCloudStore? @@ -33,6 +37,7 @@ final class AppModel { ) { self.store = store self.platform = platform + lastPlatformSyncAt = UserDefaults.standard.object(forKey: Self.lastPlatformSyncKey) as? Date account = platform.map { PersonalAccountModel(identity: $0.identity, callbackScheme: "kith") } @@ -41,6 +46,11 @@ final class AppModel { if arguments.contains("--person-demo") { selectedPersonID = UUID(uuidString: "11111111-1111-1111-1111-111111111111") } + if arguments.contains("--sync-status-demo") { + isShowingConnection = true + platformPendingMutationCount = 2 + lastPlatformSyncAt = nil + } } private static func isDemoLaunch(_ arguments: [String]) -> Bool { @@ -49,6 +59,7 @@ final class AppModel { || arguments.contains("--person-demo") || arguments.contains("--onboarding-demo") || arguments.contains("--onboarding-resume-demo") + || arguments.contains("--sync-status-demo") } var visiblePeople: [Person] { @@ -63,7 +74,9 @@ final class AppModel { defer { isLoading = false } let arguments = ProcessInfo.processInfo.arguments do { - if arguments.contains("--ui-demo") || arguments.contains("--person-demo") { + if arguments.contains("--sync-status-demo") { + document = .empty + } else if arguments.contains("--ui-demo") || arguments.contains("--person-demo") { document = .sample } else if arguments.contains("--onboarding-resume-demo") { let person = Person( @@ -95,6 +108,7 @@ final class AppModel { static let onboardingCompletionKey = "kith.onboarding.completed.v1" static let onboardingPersonKey = "kith.onboarding.person.v1" + static let lastPlatformSyncKey = "kith.hub.last-sync.v1" static let demoOnboardingPersonID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! var onboardingPerson: Person? { @@ -233,26 +247,52 @@ final class AppModel { Task { do { try await store.save(snapshot) - try await cloud?.save(snapshot) } catch { message = "Could not save." + return + } + do { + try await cloud?.save(snapshot) + } catch { + message = "Saved on this iPhone. iCloud did not update." } } } func syncFromPlatform() async { + guard !ProcessInfo.processInfo.arguments.contains("--sync-status-demo") else { return } guard let platform else { return } + platformPendingMutationCount = await platform.sync.pendingMutationCount() + guard account?.isSignedIn == true else { + isPlatformSyncing = false + platformSyncIssue = nil + return + } + guard !isPlatformSyncing else { return } + isPlatformSyncing = true + platformSyncIssue = nil + defer { isPlatformSyncing = false } do { try await enqueueLocalRecords(using: platform) let changes = try await platform.sync.synchronize() - guard !changes.isEmpty else { return } for change in changes { apply(change) } - try await store.save(document) + if !changes.isEmpty { try await store.save(document) } + platformPendingMutationCount = await platform.sync.pendingMutationCount() + let syncedAt = Date() + lastPlatformSyncAt = syncedAt + UserDefaults.standard.set(syncedAt, forKey: Self.lastPlatformSyncKey) } catch { - // The local document remains fully usable while offline. + platformPendingMutationCount = await platform.sync.pendingMutationCount() + platformSyncIssue = HubSyncIssue(error: error) } } + func refreshPlatformStatus() async { + guard !ProcessInfo.processInfo.arguments.contains("--sync-status-demo") else { return } + guard let platform else { return } + platformPendingMutationCount = await platform.sync.pendingMutationCount() + } + private func enqueueLocalRecords(using platform: PersonalPlatformConnection) async throws { for person in document.people { try await platform.sync.enqueue( @@ -274,14 +314,19 @@ final class AppModel { private func enqueueDeletions(_ ids: [UUID]) { guard let platform, !ids.isEmpty else { return } Task { - for id in ids { - try? await platform.sync.enqueue( - recordId: id.uuidString.lowercased(), - operation: .delete, - occurredAt: KithPlatformRecord.iso(.now) - ) + do { + for id in ids { + try await platform.sync.enqueue( + recordId: id.uuidString.lowercased(), + operation: .delete, + occurredAt: KithPlatformRecord.iso(.now) + ) + } + await syncFromPlatform() + } catch { + platformPendingMutationCount = await platform.sync.pendingMutationCount() + platformSyncIssue = HubSyncIssue(error: error) } - _ = try? await platform.sync.synchronize() } } @@ -294,8 +339,11 @@ final class AppModel { occurredAt: KithPlatformRecord.iso(person.updatedAt), record: KithPlatformRecord.person(person) ) - _ = try? await platform.sync.synchronize() - } catch {} + await syncFromPlatform() + } catch { + platformPendingMutationCount = await platform.sync.pendingMutationCount() + platformSyncIssue = HubSyncIssue(error: error) + } } } @@ -308,8 +356,11 @@ final class AppModel { occurredAt: KithPlatformRecord.iso(entry.happenedOn), record: KithPlatformRecord.interaction(entry, person: person) ) - _ = try? await platform.sync.synchronize() - } catch {} + await syncFromPlatform() + } catch { + platformPendingMutationCount = await platform.sync.pendingMutationCount() + platformSyncIssue = HubSyncIssue(error: error) + } } } @@ -352,3 +403,47 @@ final class AppModel { ) } } + +enum HubSyncIssue: Equatable { + case reconnect + case offline + case serviceUnavailable + case couldNotFinish + + init(error: Error) { + if let syncError = error as? PersonalSyncError { + switch syncError { + case let .server(status, _): + if status == 401 || status == 403 { + self = .reconnect + } else if status >= 500 { + self = .serviceUnavailable + } else { + self = .couldNotFinish + } + case .invalidResponse: + self = .serviceUnavailable + } + return + } + if let urlError = error as? URLError, + [.notConnectedToInternet, .networkConnectionLost, .timedOut].contains(urlError.code) { + self = .offline + return + } + self = .couldNotFinish + } + + var message: String { + switch self { + case .reconnect: + "Your connection expired. Sign in again to resume Hub sync." + case .offline: + "Kith is offline. Your changes are safe and will retry when you reconnect." + case .serviceUnavailable: + "The Hub is temporarily unavailable. Your changes are safe on this iPhone." + case .couldNotFinish: + "Hub sync could not finish. Your changes are safe and ready to retry." + } + } +} diff --git a/ios/Sources/Kith/KithConnectionView.swift b/ios/Sources/Kith/KithConnectionView.swift index 0b017e2..79541cb 100644 --- a/ios/Sources/Kith/KithConnectionView.swift +++ b/ios/Sources/Kith/KithConnectionView.swift @@ -8,58 +8,107 @@ struct KithConnectionView: View { var body: some View { NavigationStack { - VStack(spacing: 20) { - Image(systemName: "icloud") - .font(.system(size: 42)) - .foregroundStyle(KithPalette.clay) - Text("Keep Kith in sync") - .font(.title2.weight(.semibold)) - Text("Kith stays local-first. When connected, people and notes also sync through your private Significant Hobbies account.") - .multilineTextAlignment(.center) - .foregroundStyle(KithPalette.espresso.opacity(0.62)) - if let account = model.account { - if account.isSignedIn { - Label(account.session?.email ?? "Connected", systemImage: "checkmark.circle.fill") - .foregroundStyle(KithPalette.sage) - Button("Sync now") { - Task { await model.syncFromPlatform() } - } - .buttonStyle(ClayButtonStyle()) - Button("Sign out", role: .destructive) { - Task { await account.signOut() } - } - } else { - SignInWithAppleButton(.continue) { request in - account.prepareApple(request) - } onCompletion: { result in - Task { - await account.completeApple(result) - if account.isSignedIn { await model.syncFromPlatform() } + ScrollView { + VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: "person.2.circle.fill") + .font(.system(size: 42)) + .foregroundStyle(KithPalette.clay) + Text("Your people, where you need them") + .font(.title2.weight(.semibold)) + Text("Kith works without an account. Connecting adds a private Hub copy of approved people and dated notes.") + .foregroundStyle(KithPalette.espresso.opacity(0.62)) + } + + VStack(alignment: .leading, spacing: 14) { + role( + icon: "iphone", + title: "Saved on this iPhone", + detail: "Every edit appears here immediately, even offline." + ) + role( + icon: "icloud", + title: "Kept across your Apple devices", + detail: "iCloud mirrors the full Kith app while the Hub transition is underway." + ) + role( + icon: "square.grid.2x2", + title: "Visible in your private Hub", + detail: "Your Significant Hobbies account syncs approved people and dated notes." + ) + } + .padding(18) + .background(KithPalette.cream, in: RoundedRectangle(cornerRadius: 22)) + + if let account = model.account { + VStack(alignment: .leading, spacing: 14) { + if account.isSignedIn { + Label( + account.session?.email ?? "Significant Hobbies connected", + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(KithPalette.sage) + + if model.isPlatformSyncing { + Label("Syncing with your Hub…", systemImage: "arrow.triangle.2.circlepath") + .foregroundStyle(KithPalette.espresso.opacity(0.7)) + } + syncDetails + + Button("Sync now") { + Task { await model.syncFromPlatform() } + } + .buttonStyle(ClayButtonStyle()) + .disabled(model.isPlatformSyncing) + Button("Sign out", role: .destructive) { + Task { + await account.signOut() + await model.refreshPlatformStatus() + } + } + } else { + Text("Connect your Significant Hobbies account") + .font(.headline) + Text("Your local Kith stays exactly where it is. Any waiting changes sync after you connect.") + .font(.subheadline) + .foregroundStyle(KithPalette.espresso.opacity(0.62)) + syncDetails + + SignInWithAppleButton(.continue) { request in + account.prepareApple(request) + } onCompletion: { result in + Task { + await account.completeApple(result) + if account.isSignedIn { await model.syncFromPlatform() } + } + } + .signInWithAppleButtonStyle(.black) + .frame(minHeight: 46) + .disabled(account.isConnecting) + Button("Use Google instead") { + Task { + await account.connect() + if account.isSignedIn { await model.syncFromPlatform() } + } + } + .buttonStyle(ClayButtonStyle()) + .disabled(account.isConnecting) } - } - .signInWithAppleButtonStyle(.black) - .frame(minHeight: 46) - .disabled(account.isConnecting) - Button("Continue with Google") { - Task { - await account.connect() - await model.syncFromPlatform() + + if account.isConnecting { ProgressView() } + if let error = account.errorMessage { + Text(error).font(.footnote).foregroundStyle(KithPalette.rust) } } - .buttonStyle(ClayButtonStyle()) - .disabled(account.isConnecting) - } - if account.isConnecting { ProgressView() } - if let error = account.errorMessage { - Text(error).font(.footnote).foregroundStyle(KithPalette.rust) + .padding(18) + .background(KithPalette.cream, in: RoundedRectangle(cornerRadius: 22)) + } else { + Text("Connection setup is unavailable in this build.") + .foregroundStyle(KithPalette.rust) } - } else { - Text("Connection setup is unavailable.") - .foregroundStyle(KithPalette.rust) } - Spacer() + .padding(28) } - .padding(28) .background(KithPalette.linen) .navigationTitle("Connection") .toolbar { @@ -67,6 +116,52 @@ struct KithConnectionView: View { Button("Done") { dismiss() } } } + .task { await model.refreshPlatformStatus() } + } + } + + @ViewBuilder + private var syncDetails: some View { + if model.platformPendingMutationCount > 0 { + Label( + waitingText(model.platformPendingMutationCount), + systemImage: "clock.arrow.circlepath" + ) + .font(.footnote) + .foregroundStyle(KithPalette.espresso.opacity(0.72)) + } + if let issue = model.platformSyncIssue { + Label(issue.message, systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(KithPalette.rust) + } + if let lastSync = model.lastPlatformSyncAt { + HStack(spacing: 4) { + Text("Last Hub sync") + Text(lastSync, style: .relative) + } + .font(.footnote) + .foregroundStyle(KithPalette.espresso.opacity(0.55)) + } + } + + private func waitingText(_ count: Int) -> String { + count == 1 + ? "1 change is waiting safely" + : "\(count) changes are waiting safely" + } + + private func role(icon: String, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .frame(width: 24) + .foregroundStyle(KithPalette.clay) + VStack(alignment: .leading, spacing: 3) { + Text(title).font(.subheadline.weight(.semibold)) + Text(detail) + .font(.footnote) + .foregroundStyle(KithPalette.espresso.opacity(0.58)) + } } } } diff --git a/ios/Sources/Kith/RootView.swift b/ios/Sources/Kith/RootView.swift index eab8e5d..d548cda 100644 --- a/ios/Sources/Kith/RootView.swift +++ b/ios/Sources/Kith/RootView.swift @@ -90,7 +90,7 @@ struct FieldChrome: View { .frame(width: 44, height: 44) .background(KithPalette.cream, in: Circle()) } - .accessibilityLabel("Cloudflare connection") + .accessibilityLabel("Significant Hobbies connection") Button { model.isShowingList.toggle() } label: { diff --git a/ios/Tests/KithAppTests/HubSyncIssueTests.swift b/ios/Tests/KithAppTests/HubSyncIssueTests.swift new file mode 100644 index 0000000..772fc04 --- /dev/null +++ b/ios/Tests/KithAppTests/HubSyncIssueTests.swift @@ -0,0 +1,32 @@ +import Foundation +import PersonalSyncKit +import XCTest +@testable import Kith + +final class HubSyncIssueTests: XCTestCase { + func testExpiredSessionsAskForReconnection() { + XCTAssertEqual( + HubSyncIssue(error: PersonalSyncError.server(status: 401, message: "expired")), + .reconnect + ) + XCTAssertEqual( + HubSyncIssue(error: PersonalSyncError.server(status: 403, message: "scope")), + .reconnect + ) + } + + func testTransportAndServiceFailuresStayDistinct() { + XCTAssertEqual( + HubSyncIssue(error: URLError(.notConnectedToInternet)), + .offline + ) + XCTAssertEqual( + HubSyncIssue(error: PersonalSyncError.server(status: 503, message: "later")), + .serviceUnavailable + ) + XCTAssertEqual( + HubSyncIssue(error: PersonalSyncError.server(status: 422, message: "invalid")), + .couldNotFinish + ) + } +} diff --git a/ios/Tests/KithUITests/KithUITests.swift b/ios/Tests/KithUITests/KithUITests.swift index 0ffa472..0c91908 100644 --- a/ios/Tests/KithUITests/KithUITests.swift +++ b/ios/Tests/KithUITests/KithUITests.swift @@ -87,4 +87,14 @@ final class KithUITests: XCTestCase { XCTAssertTrue(app.staticTexts["Kith"].waitForExistence(timeout: 4)) XCTAssertFalse(app.staticTexts["The people you keep close."].exists) } + + func testConnectionExplainsStorageRolesAndWaitingChanges() { + let app = launch(["--sync-status-demo"]) + + XCTAssertTrue(app.navigationBars["Connection"].waitForExistence(timeout: 4)) + XCTAssertTrue(app.staticTexts["Saved on this iPhone"].exists) + XCTAssertTrue(app.staticTexts["Kept across your Apple devices"].exists) + XCTAssertTrue(app.staticTexts["Visible in your private Hub"].exists) + XCTAssertTrue(app.staticTexts["2 changes are waiting safely"].exists) + } } diff --git a/ios/project.yml b/ios/project.yml index fddc122..25ec805 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -21,7 +21,7 @@ settings: packages: PersonalSyncKit: url: https://github.com/Significant-Hobbies/significanthobbies.git - revision: 007cc255e75d291d10528ea9b123759d7e050be3 + revision: e350a86b2c255861f528d5a22bca1196d5a2898e targets: KithCore: type: framework @@ -81,6 +81,19 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.significanthobbies.kith.core-tests GENERATE_INFOPLIST_FILE: YES + KithAppTests: + type: bundle.unit-test + platform: iOS + sources: Tests/KithAppTests + dependencies: + - target: Kith + - package: PersonalSyncKit + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.significanthobbies.kith.app-tests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Kith.app/Kith" + BUNDLE_LOADER: "$(TEST_HOST)" KithUITests: type: bundle.ui-testing platform: iOS @@ -97,10 +110,12 @@ schemes: targets: Kith: all KithCoreTests: [test] + KithAppTests: [test] KithUITests: [test] test: targets: - KithCoreTests + - KithAppTests - KithUITests gatherCoverageData: true run: