Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
129 changes: 129 additions & 0 deletions Friendly/Sources/AddFriendService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import Foundation

@MainActor
final class AddFriendService {
private let storage: Storage = .shared
private let networkClient: NetworkClient = .meetacy
private let userDefaults: UserDefaults = .standard
private let reconciliationUserIdKey = "friends.add.reconciliationUserId"
private var isAddingFriend: Bool = false

func add(_ command: AddFriendCommand) async throws(AddError) {
guard !isAddingFriend else { throw .alreadyProcessing }
isAddingFriend = true
defer { isAddingFriend = false }

let authorization: Authorization
do {
authorization = try storage.loadAuthorization()
} catch {
throw .retryable
}

markForReconciliation(authorization: authorization)

do {
try await networkClient.friendsAdd(
authorization: authorization,
token: command.token,
id: command.id,
)
} catch let error {
switch error {
case .expiredToken:
clearReconciliation()
throw .invalidInvite
case .serverError(let statusCode):
if (400..<500).contains(statusCode) {
clearReconciliation()
throw .invalidInvite
}
throw .retryable
case .unauthorized:
clearReconciliation()
throw .retryable
case .ioError:
throw .retryable
}
}

do {
try storage.addFriend()
clearReconciliation()
} catch {
// The server operation succeeded. Reconcile the local gate on next launch.
}
}

func hasPendingReconciliation(
authorization: Authorization,
) -> Bool {
guard let userId = userDefaults.string(
forKey: reconciliationUserIdKey,
) else {
return false
}

guard userId == String(authorization.id.int64) else {
clearReconciliation()
return false
}
return true
}

func reconcile() async throws(ReconciliationError) -> Bool {
let authorization: Authorization
do {
authorization = try storage.loadAuthorization()
} catch {
throw .retryable
}

let network: NetworkDetails
do {
network = try await networkClient.networkDetails(
authorization: authorization,
)
} catch {
throw .retryable
}

guard !network.friends.isEmpty else {
clearReconciliation()
return false
}

do {
try storage.addFriend()
clearReconciliation()
} catch {
// Keep the marker and retry persistence on the next launch.
}
return true
}

func clearReconciliation() {
userDefaults.removeObject(forKey: reconciliationUserIdKey)
}

private func markForReconciliation(
authorization: Authorization,
) {
userDefaults.set(
String(authorization.id.int64),
forKey: reconciliationUserIdKey,
)
}

enum AddError: Swift.Error {
case alreadyProcessing
case invalidInvite
case retryable
}

enum ReconciliationError: Swift.Error {
case retryable
}

static let shared = AddFriendService()
}
78 changes: 68 additions & 10 deletions Friendly/Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,24 @@ struct ContentView: View {

var body: some View {
destinationView
.animation(.easeInOut(duration: 0.3), value: viewModel.destination)
.transition(.opacity)
.onAppear {
viewModel.appear()
}
.onOpenURL { url in
guard let deeplink = Deeplink.of(url: url) else { return }
switch deeplink {
.animation(.easeInOut(duration: 0.3), value: viewModel.destination)
.transition(.opacity)
.onAppear {
viewModel.appear()
}
.onOpenURL { url in
guard let deeplink = Deeplink.parseOf(url: url) else {
viewModel.onInvalidFriendLink()
return
}
switch deeplink {
case let .addFriend(id, token):
viewModel.onAddFriend(id: id, token: token)
}
}
.alert(item: $viewModel.friendLinkAlert) { alert in
friendLinkAlert(alert)
}
}
}

private var destinationView: some View {
Expand All @@ -32,7 +38,7 @@ struct ContentView: View {
case .main:
MainView(
routeToSignUp: viewModel.routeToSignUp,
addFriend: $viewModel.addFriend,
route: $viewModel.mainRoute,
)
case .qrAddFriend:
ScanToUseAppView(
Expand All @@ -41,6 +47,58 @@ struct ContentView: View {
onSuccess: viewModel.onAddFriendWithQr,
)
}

if viewModel.isProcessingFriendAccess {
Color(uiColor: .systemBackground)
.ignoresSafeArea()
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}

private func friendLinkAlert(
_ alert: ContentViewModel.FriendLinkAlert,
) -> Alert {
switch alert {
case .authenticationRequired:
Alert(
title: Text(.friendLinkAuthRequiredTitle),
message: Text(.friendLinkAuthRequiredMessage),
dismissButton: .default(Text(.buttonBaseClose)),
Comment thread
alex-npmn marked this conversation as resolved.
Outdated
)
case .alreadyProcessing:
Alert(
title: Text(.friendLinkProcessingTitle),
message: Text(.friendLinkProcessingMessage),
dismissButton: .default(Text(.buttonBaseClose)),
)
case .invalidInvite:
Alert(
title: Text(.friendLinkInvalidTitle),
message: Text(.friendLinkInvalidMessage),
dismissButton: .default(Text(.buttonBaseClose)),
)
case .retryInvite:
Alert(
title: Text(.friendLinkRetryTitle),
message: Text(.friendLinkRetryMessage),
primaryButton: .default(Text(.friendLinkRetryButton)) {
viewModel.retryFriendLink()
},
secondaryButton: .cancel(Text(.friendLinkCancelButton)) {
viewModel.cancelFriendLink()
},
)
case .retryReconciliation:
Alert(
title: Text(.friendGateRetryTitle),
message: Text(.friendGateRetryMessage),
primaryButton: .default(Text(.friendLinkRetryButton)) {
viewModel.retryFriendAccessReconciliation()
},
secondaryButton: .cancel(Text(.friendLinkCancelButton)),
)
}
}
}
Loading
Loading