Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .fleet/design-review-ios.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
{"width": 390, "path": "ios/artifacts/simulator/rest-timer.png"},
{"width": 390, "path": "ios/artifacts/simulator/plan.png"},
{"width": 390, "path": "ios/artifacts/simulator/history.png"},
{"width": 390, "path": "ios/artifacts/simulator/account-sync.png"},
{"width": 390, "path": "ios/artifacts/simulator/sync-conflict.png"},
{"width": 390, "path": "ios/artifacts/simulator/accessibility-xl.png"}
],
"projectCheck": {"command": "cd ios && ./scripts/check.sh", "status": "pass"},
Expand Down
32 changes: 14 additions & 18 deletions ios/Setline.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
BE5FDC83C2B64F526F64979C /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 824EFFB1023C857CE4F1FE1C /* RootView.swift */; };
C66AD84C2D8FAD24149B2445 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */; };
C7E1EDD15D038B815CB78D27 /* SetlineApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 608F81A574360691A58B8581 /* SetlineApp.swift */; };
C953D5949A40703738078819 /* CloudSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = F51113C8A77901F17242774D /* CloudSync.swift */; };
D9647D921553DE22AB36226A /* SetlineCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
E85DFE444A157EC32C671B9B /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B839442BFCCB000A563CD704 /* AppModel.swift */; };
EC589E605EEE16DA5E0F613E /* SetlineCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */; };
EF0C5CDD67CA95C3309607B0 /* NativeAccountClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */; };
F5D0CC71C826F541575E7D99 /* Persistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8358BE39000A0F7E1BB90 /* Persistence.swift */; };
/* End PBXBuildFile section */

Expand Down Expand Up @@ -78,6 +80,8 @@
/* Begin PBXFileReference section */
0DE14EAFD3C581FCA8D09CA7 /* SetlineCore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SetlineCore.framework; sourceTree = BUILT_PRODUCTS_DIR; };
1211B40E359F4BBD82A557A9 /* SetlineCoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineCoreTests.swift; sourceTree = "<group>"; };
15268979096821BD9ABB22E0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAccountClient.swift; sourceTree = "<group>"; };
3C2CB1821B5B5AE4C75873C6 /* Design.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Design.swift; sourceTree = "<group>"; };
594DC48A6CD7B68259549A12 /* Progression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Progression.swift; sourceTree = "<group>"; };
608F81A574360691A58B8581 /* SetlineApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineApp.swift; sourceTree = "<group>"; };
Expand All @@ -91,6 +95,7 @@
B839442BFCCB000A563CD704 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
BA87F29CCAE78D4F24E75B1F /* Setline.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Setline.app; sourceTree = BUILT_PRODUCTS_DIR; };
DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecondaryViews.swift; sourceTree = "<group>"; };
F51113C8A77901F17242774D /* CloudSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSync.swift; sourceTree = "<group>"; };
F52F5D64B932696BAEDEE0AC /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
FEE9CEC1CE889DCD183F4CF7 /* SetlineUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetlineUITests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -129,6 +134,8 @@
children = (
B839442BFCCB000A563CD704 /* AppModel.swift */,
3C2CB1821B5B5AE4C75873C6 /* Design.swift */,
15268979096821BD9ABB22E0 /* Info.plist */,
27A4007C0EBCEA2C015905F8 /* NativeAccountClient.swift */,
824EFFB1023C857CE4F1FE1C /* RootView.swift */,
DFAE6EB21BC26F97140F4FEE /* SecondaryViews.swift */,
608F81A574360691A58B8581 /* SetlineApp.swift */,
Expand All @@ -153,6 +160,7 @@
5745423AFF1E15935A399E22 /* SetlineCore */ = {
isa = PBXGroup;
children = (
F51113C8A77901F17242774D /* CloudSync.swift */,
B0859DE334CC4D97FFE0DFDA /* Domain.swift */,
65E8358BE39000A0F7E1BB90 /* Persistence.swift */,
594DC48A6CD7B68259549A12 /* Progression.swift */,
Expand Down Expand Up @@ -346,6 +354,7 @@
files = (
E85DFE444A157EC32C671B9B /* AppModel.swift in Sources */,
3CE2DEDE101DA826A45AC5F8 /* Design.swift in Sources */,
EF0C5CDD67CA95C3309607B0 /* NativeAccountClient.swift in Sources */,
BE5FDC83C2B64F526F64979C /* RootView.swift in Sources */,
A949A76428AB4118FB259A70 /* SecondaryViews.swift in Sources */,
C7E1EDD15D038B815CB78D27 /* SetlineApp.swift in Sources */,
Expand All @@ -365,6 +374,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
C953D5949A40703738078819 /* CloudSync.swift in Sources */,
7DFE7762F43344AFC746CDC6 /* Domain.swift in Sources */,
F5D0CC71C826F541575E7D99 /* Persistence.swift in Sources */,
309CBBBA2928642CBD999F67 /* Progression.swift in Sources */,
Expand Down Expand Up @@ -454,14 +464,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Setline;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_FILE = Sources/Setline/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand All @@ -470,7 +473,7 @@
PRODUCT_NAME = Setline;
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
Expand Down Expand Up @@ -502,14 +505,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Setline;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.healthcare-fitness";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_FILE = Sources/Setline/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
Expand All @@ -518,7 +514,7 @@
PRODUCT_NAME = Setline;
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
TARGETED_DEVICE_FAMILY = "1,2";
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
Expand Down
225 changes: 224 additions & 1 deletion ios/Sources/Setline/AppModel.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AuthenticationServices
import Foundation
import Observation
import SetlineCore
Expand All @@ -12,13 +13,33 @@ final class AppModel {
var message: String?
var importPreview: SetlineDocument?
var isImportConfirmationPresented = false
var account: SetlineAccount?
var isAccountBusy = false
var cloudConflict: SetlineCloudSnapshot?
var accountMessage: String?

private let store: SetlineStore
private let accountClient: SetlineNativeAccountClient
private let webAuthenticator: SetlineWebAuthenticator
private var remoteRevision: Int?
private var syncRequested = false
private var isSyncing = false
private var deferredConflict: SetlineCloudSnapshot?

init(store: SetlineStore = SetlineStore()) {
init(
store: SetlineStore = SetlineStore(),
accountClient: SetlineNativeAccountClient = SetlineNativeAccountClient(),
webAuthenticator: SetlineWebAuthenticator = SetlineWebAuthenticator()
) {
self.store = store
self.accountClient = accountClient
self.webAuthenticator = webAuthenticator
if ProcessInfo.processInfo.arguments.contains("--plan-demo") { selectedTab = 1 }
if ProcessInfo.processInfo.arguments.contains("--history-demo") { selectedTab = 2 }
if ProcessInfo.processInfo.arguments.contains("--account-demo") ||
ProcessInfo.processInfo.arguments.contains("--account-conflict-demo") {
selectedTab = 3
}
}

func load() async {
Expand All @@ -40,6 +61,32 @@ final class AppModel {
try document.completeCurrent(with: [SetSegment(weight: 40, repetitions: 8)])
isWorkoutPresented = true
}
if ProcessInfo.processInfo.arguments.contains("--account-demo") {
account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com")
document.syncState = .synced
document.lastSyncedAt = Date().addingTimeInterval(-240)
} else if ProcessInfo.processInfo.arguments.contains("--account-conflict-demo") {
account = SetlineAccount(name: "Sarthak", email: "sarthak@example.com")
document.syncState = .conflict
var accountDocument = document
if let template = accountDocument.templates.first {
accountDocument.history = [
WorkoutSession(
templateID: template.id,
templateName: template.name,
startedAt: Date().addingTimeInterval(-3_600),
completedAt: Date().addingTimeInterval(-2_700),
steps: []
),
]
}
cloudConflict = SetlineCloudSnapshot(
document: SetlineCloudDocument(document: accountDocument),
revision: 3
)
} else {
await restoreAccount()
}
} catch {
document = .sample
message = error.localizedDescription
Expand Down Expand Up @@ -143,6 +190,11 @@ final class AppModel {
self.importPreview = nil
isImportConfirmationPresented = false
message = "Setline data replaced."
if account != nil, deferredConflict == nil, cloudConflict == nil {
document.syncState = .pending
try await store.save(document)
Task { await self.queueSync() }
}
} catch {
message = error.localizedDescription
}
Expand All @@ -153,17 +205,188 @@ final class AppModel {
try await store.reset()
document = .sample
message = "Local data reset."
if account != nil, deferredConflict == nil, cloudConflict == nil {
document.syncState = .pending
try await store.save(document)
Task { await self.queueSync() }
}
} catch {
message = error.localizedDescription
}
}

func connectAccount() async {
isAccountBusy = true
accountMessage = nil
defer { isAccountBusy = false }
do {
let url = await accountClient.googleStartURL
let code = try await webAuthenticator.authenticate(at: url)
account = try await accountClient.exchangeHandoff(code)
try await reconcileAccountCopy()
} catch let error as NSError
where error.domain == ASWebAuthenticationSessionErrorDomain && error.code == 1 {
accountMessage = nil
} catch {
accountMessage = friendlyMessage(for: error)
}
}

func syncNow() async {
guard account != nil else { return }
if let deferredConflict {
self.deferredConflict = nil
cloudConflict = deferredConflict
return
}
await queueSync()
}

func keepDeviceCopy() async {
guard let conflict = cloudConflict else { return }
cloudConflict = nil
deferredConflict = nil
remoteRevision = conflict.revision
await queueSync()
}

func useAccountCopy() async {
guard let conflict = cloudConflict else { return }
do {
let restored = conflict.document.localDocument()
try await store.replace(with: restored)
document = restored
remoteRevision = conflict.revision
cloudConflict = nil
deferredConflict = nil
accountMessage = "Account copy restored on this iPhone."
} catch {
accountMessage = friendlyMessage(for: error)
}
}

func decideConflictLater() {
deferredConflict = cloudConflict
cloudConflict = nil
document.syncState = .conflict
Task { try? await store.save(document) }
}

func signOut() async {
await accountClient.signOut()
account = nil
remoteRevision = nil
cloudConflict = nil
deferredConflict = nil
document.syncState = .deviceOnly
try? await store.save(document)
accountMessage = "Signed out. Your workouts remain on this iPhone."
}

func deleteAccount() async {
isAccountBusy = true
defer { isAccountBusy = false }
do {
try await accountClient.deleteAccount()
account = nil
remoteRevision = nil
cloudConflict = nil
deferredConflict = nil
document.syncState = .deviceOnly
try await store.save(document)
accountMessage = "Setline account and its private cloud copy were deleted."
} catch {
accountMessage = friendlyMessage(for: error)
}
}

private func restoreAccount() async {
do {
account = try await accountClient.restoreAccount()
if account != nil { try await reconcileAccountCopy() }
} catch {
account = nil
document.syncState = .deviceOnly
accountMessage = friendlyMessage(for: error)
}
}

private func reconcileAccountCopy() async throws {
let remote = try await accountClient.fetchState()
guard let remote else {
let saved = try await accountClient.pushState(
SetlineCloudDocument(document: document),
baseRevision: nil
)
remoteRevision = saved.revision
await markSynced()
return
}
remoteRevision = remote.revision
if remote.document == SetlineCloudDocument(document: document) {
await markSynced()
} else {
document.syncState = .conflict
try await store.save(document)
cloudConflict = remote
}
}

private func queueSync() async {
syncRequested = true
guard !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
while syncRequested {
syncRequested = false
document.syncState = .pending
try? await store.save(document)
do {
let saved = try await accountClient.pushState(
SetlineCloudDocument(document: document),
baseRevision: remoteRevision
)
remoteRevision = saved.revision
await markSynced()
} catch let NativeAccountError.conflict(conflict) {
document.syncState = .conflict
try? await store.save(document)
cloudConflict = conflict
return
} catch {
document.syncState = .failed
try? await store.save(document)
accountMessage = friendlyMessage(for: error)
return
}
}
}

private func markSynced() async {
document.syncState = .synced
document.lastSyncedAt = .now
try? await store.save(document)
accountMessage = "Private account copy is up to date."
}

private func friendlyMessage(for error: Error) -> String {
if let native = error as? NativeAccountError {
return native.errorDescription ?? "Setline account service is unavailable."
}
return "Setline could not complete that account action. Try again."
}

private func mutate(_ operation: (inout SetlineDocument) throws -> Void) async {
do {
var next = document
try operation(&next)
try await store.save(next)
document = next
if account != nil, deferredConflict == nil, cloudConflict == nil {
document.syncState = .pending
try await store.save(document)
Task { await self.queueSync() }
}
} catch {
message = error.localizedDescription
}
Expand Down
Loading
Loading