diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..ad6955d --- /dev/null +++ b/.mise.toml @@ -0,0 +1,2 @@ +[tools] +tuist = "4.140.0" diff --git a/Friendly/Sources/AuthBindings/AuthBindingsView.swift b/Friendly/Sources/AuthBindings/AuthBindingsView.swift new file mode 100644 index 0000000..13add5a --- /dev/null +++ b/Friendly/Sources/AuthBindings/AuthBindingsView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +struct AuthBindingsView: View { + let onEmailLinked: (String) -> Void + + @State private var viewModel = AuthBindingsViewModel() + + var body: some View { + ScrollView { + contentView + .padding() + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle(String(localized: .authBindingsNavigationTitle)) + .navigationBarTitleDisplayMode(.inline) + .alert( + String(localized: .authBindingsProviderUnavailableTitle), + isPresented: $viewModel.showProviderUnavailableAlert, + ) { + Button(String(localized: .signUpErrorOk), role: .cancel) {} + } message: { + Text(.authBindingsProviderUnavailableMessage) + } + } + + private var contentView: some View { + VStack(alignment: .leading, spacing: 16) { + titleLabel + subtitleLabel + emailBindingNavigationLink + AppleBindingButton(viewModel: viewModel) + GoogleBindingButton(viewModel: viewModel) + } + } + + private var titleLabel: some View { + Text(.authBindingsTitle) + .font(.title2) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + + private var subtitleLabel: some View { + Text(.authBindingsSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + + private var emailBindingNavigationLink: some View { + NavigationLink { + EmailBindingView(onSuccess: onEmailLinked) + } label: { + EmailBindingButtonLabel() + } + .buttonStyle(.borderedProminent) + } +} + +private struct EmailBindingButtonLabel: View { + var body: some View { + HStack { + Image(systemName: "envelope") + Text(.profileEditBindEmailButton) + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } +} + +private struct AppleBindingButton: View { + let viewModel: AuthBindingsViewModel + + var body: some View { + Button(action: { viewModel.tapAppleAuthorization() }) { + HStack { + Image(systemName: "apple.logo") + Text(.authBindingsAppleButton) + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.bordered) + } +} + +private struct GoogleBindingButton: View { + let viewModel: AuthBindingsViewModel + + var body: some View { + Button(action: { viewModel.tapGoogleAuthorization() }) { + HStack { + Image(systemName: "globe") + Text(.authBindingsGoogleButton) + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.bordered) + } +} diff --git a/Friendly/Sources/AuthBindings/AuthBindingsViewModel.swift b/Friendly/Sources/AuthBindings/AuthBindingsViewModel.swift new file mode 100644 index 0000000..56008c8 --- /dev/null +++ b/Friendly/Sources/AuthBindings/AuthBindingsViewModel.swift @@ -0,0 +1,15 @@ +import Foundation + +@MainActor +@Observable +class AuthBindingsViewModel { + var showProviderUnavailableAlert: Bool = false + + func tapAppleAuthorization() { + showProviderUnavailableAlert = true + } + + func tapGoogleAuthorization() { + showProviderUnavailableAlert = true + } +} diff --git a/Friendly/Sources/AuthBindings/ConfirmationCode.swift b/Friendly/Sources/AuthBindings/ConfirmationCode.swift new file mode 100644 index 0000000..f0e2461 --- /dev/null +++ b/Friendly/Sources/AuthBindings/ConfirmationCode.swift @@ -0,0 +1,15 @@ +struct ConfirmationCode { + let int: Int + + init?(_ value: String) { + let digits = Self.sanitize(value) + guard digits.count == 8, let int = Int(digits) else { + return nil + } + self.int = int + } + + static func sanitize(_ value: String) -> String { + String(value.filter(\.isNumber).prefix(8)) + } +} diff --git a/Friendly/Sources/AuthBindings/EmailAuthWorker.swift b/Friendly/Sources/AuthBindings/EmailAuthWorker.swift new file mode 100644 index 0000000..125f35c --- /dev/null +++ b/Friendly/Sources/AuthBindings/EmailAuthWorker.swift @@ -0,0 +1,68 @@ +import Foundation + +final class EmailAuthWorker { + private let networkClient: NetworkClient + + init(networkClient: NetworkClient = .meetacy) { + self.networkClient = networkClient + } + + func requestBindingCode( + authorization: Authorization, + email: String, + ) async throws { + do { + try await networkClient.emailLink( + authorization: authorization, + email: email, + ) + try Task.checkCancellation() + } catch { + try Task.checkCancellation() + throw error + } + } + + func confirmBinding( + authorization: Authorization, + code: Int, + ) async throws { + do { + try await networkClient.emailConfirm( + authorization: authorization, + code: code, + ) + try Task.checkCancellation() + } catch { + try Task.checkCancellation() + throw error + } + } + + func requestLoginCode(email: String) async throws { + do { + try await networkClient.authEmail(email: email) + try Task.checkCancellation() + } catch { + try Task.checkCancellation() + throw error + } + } + + func login( + email: String, + code: Int, + ) async throws -> Authorization { + do { + let authorization = try await networkClient.authLogin( + email: email, + code: code, + ) + try Task.checkCancellation() + return authorization + } catch { + try Task.checkCancellation() + throw error + } + } +} diff --git a/Friendly/Sources/AuthBindings/EmailBindingView.swift b/Friendly/Sources/AuthBindings/EmailBindingView.swift new file mode 100644 index 0000000..1f5ed82 --- /dev/null +++ b/Friendly/Sources/AuthBindings/EmailBindingView.swift @@ -0,0 +1,190 @@ +import SwiftUI + +struct EmailBindingView: View { + let onSuccess: (String) -> Void + + @State private var viewModel = EmailBindingViewModel() + @FocusState private var isCodeInputFocused: Bool + + var body: some View { + ScrollView { + contentView + .padding() + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle(String(localized: .profileEditBindEmailSheetTitle)) + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + viewModel.cancelTasks() + } + } + + private var contentView: some View { + VStack(alignment: .leading, spacing: 16) { + titleLabel + subtitleLabel + detailsLabel + emailTextField + changeEmailButton + sendCodeButton + resendTimerView + verificationCodeInputView + confirmCodeButton + statusMessageView + } + } + + private var titleLabel: some View { + Text(.profileEditBindEmailTitle) + .font(.title3) + .fontWeight(.semibold) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + + private var subtitleLabel: some View { + Text(.profileEditBindEmailSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + + private var detailsLabel: some View { + Text(.emailBindingDetails) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + + private var emailTextField: some View { + TextField( + String(localized: .profileEditBindEmailEmailExample), + text: Binding( + get: { viewModel.email }, + set: { viewModel.email = $0 } + ) + ) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .disabled(viewModel.isEmailLocked) + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + @ViewBuilder + private var changeEmailButton: some View { + if viewModel.isEmailLocked { + Button { + viewModel.resetEmailRequest() + } label: { + Text(.emailChangeAddress) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + + private var sendCodeButton: some View { + Button(action: { viewModel.requestCode() }) { + sendCodeButtonLabel + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canRequestCode) + } + + @ViewBuilder + private var sendCodeButtonLabel: some View { + if viewModel.isSendingCode { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } else { + Text(.profileEditBindEmailSendCode) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + } + + @ViewBuilder + private var resendTimerView: some View { + if viewModel.remainingSeconds > 0 { + Text(.emailBindingResendAfter) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + Text(verbatim: viewModel.formattedTimer) + .font(.headline.monospacedDigit()) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + private var verificationCodeInputView: some View { + VerificationCodeInputView( + code: Binding( + get: { viewModel.code }, + set: { viewModel.updateCode($0) } + ), + isFocused: $isCodeInputFocused, + isError: viewModel.status == .invalidCode, + ) + } + + private var confirmCodeButton: some View { + Button(action: { + Task { + do { + let email = try await viewModel.confirmCode() + onSuccess(email) + } catch { + // The view model exposes the error through its status. + } + } + }) { + confirmCodeButtonLabel + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canConfirm) + } + + @ViewBuilder + private var confirmCodeButtonLabel: some View { + if viewModel.isConfirmingCode { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } else { + Text(.profileEditBindEmailConfirm) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + } + + @ViewBuilder + private var statusMessageView: some View { + switch viewModel.status { + case .idle: + EmptyView() + case .success: + Text(.profileEditBindEmailSuccess) + .font(.footnote) + .foregroundStyle(.green) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + case .invalidEmail: + InlineErrorView(.emailErrorInvalid) + case .alreadyUsed: + InlineErrorView(.emailBindingErrorUsed) + case .invalidCode: + InlineErrorView(.emailErrorInvalidCode) + case .unauthorized: + InlineErrorView(.emailErrorUnauthorized) + case .networkError: + InlineErrorView(.profileEditBindEmailError) + } + } +} diff --git a/Friendly/Sources/AuthBindings/EmailBindingViewModel.swift b/Friendly/Sources/AuthBindings/EmailBindingViewModel.swift new file mode 100644 index 0000000..1309f1d --- /dev/null +++ b/Friendly/Sources/AuthBindings/EmailBindingViewModel.swift @@ -0,0 +1,168 @@ +import Foundation + +@MainActor +@Observable +class EmailBindingViewModel { + private let worker = EmailAuthWorker() + private let storage: Storage = .shared + private let emailCodeFlow: EmailCodeFlow + private var emailCodeState: EmailCodeFlow.State + + var email: String { + get { emailCodeState.email } + set { + emailCodeFlow.email = newValue + clearStatus() + } + } + + var status: Status = .idle + + private var sendTask: Task? + private var confirmTask: Task? + + init() { + let emailCodeFlow = EmailCodeFlow() + self.emailCodeFlow = emailCodeFlow + self.emailCodeState = emailCodeFlow.state() + emailCodeFlow.observeState { [weak self] state in + self?.emailCodeState = state + } + } + + func updateCode(_ value: String) { + emailCodeFlow.updateCode(value) + clearStatus() + } + + func requestCode() { + guard emailCodeFlow.canRequestCode else { return } + sendTask?.cancel() + let emailCodeFlow = emailCodeFlow + let worker = worker + let storage = storage + sendTask = Task { [weak self] in + self?.status = .idle + emailCodeFlow.isSendingCode = true + defer { emailCodeFlow.isSendingCode = false } + + do { + let email = try emailCodeFlow.requestEmail() + let authorization = try storage.loadAuthorization() + try await worker.requestBindingCode( + authorization: authorization, + email: email, + ) + emailCodeFlow.didRequestCode(for: email) + } catch is CancellationError { + return + } catch NetworkClient.EmailLinkError.alreadyUsed { + self?.status = .alreadyUsed + } catch NetworkClient.EmailLinkError.unauthorized { + self?.status = .unauthorized + } catch EmailCodeFlow.Error.invalidEmail { + self?.status = .invalidEmail + } catch { + self?.status = .networkError + } + } + } + + func confirmCode() async throws -> String { + guard emailCodeFlow.canConfirm else { + throw EmailCodeFlow.Error.invalidCode + } + confirmTask?.cancel() + let emailCodeFlow = emailCodeFlow + let worker = worker + let storage = storage + status = .idle + emailCodeFlow.isConfirmingCode = true + defer { + emailCodeFlow.isConfirmingCode = false + confirmTask = nil + } + + do { + guard let email = emailCodeFlow.requestedEmail else { + throw EmailCodeFlow.Error.invalidEmail + } + let code = try emailCodeFlow.codeToConfirm() + let authorization = try storage.loadAuthorization() + let task: Task = Task { + try await worker.confirmBinding( + authorization: authorization, + code: code.int, + ) + } + confirmTask = task + + try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + try Task.checkCancellation() + guard !task.isCancelled else { + throw CancellationError() + } + status = .success + return email + } catch { + updateStatus(for: error) + throw error + } + } + + func resetEmailRequest() { + sendTask?.cancel() + confirmTask?.cancel() + emailCodeFlow.resetEmailRequest() + status = .idle + } + + func cancelTasks() { + sendTask?.cancel() + confirmTask?.cancel() + emailCodeFlow.cancelCooldown() + } + + private func clearStatus() { + if status != .idle { + status = .idle + } + } + + private func updateStatus(for error: Swift.Error) { + switch error { + case is CancellationError: + break + case NetworkClient.EmailConfirmError.invalidOrExpiredCode: + status = .invalidCode + case NetworkClient.EmailConfirmError.unauthorized: + status = .unauthorized + case EmailCodeFlow.Error.invalidEmail: + status = .invalidEmail + case EmailCodeFlow.Error.invalidCode: + status = .invalidCode + default: + status = .networkError + } + } + + enum Status { + case idle + case success + case invalidEmail + case alreadyUsed + case invalidCode + case unauthorized + case networkError + } +} + +extension EmailBindingViewModel: EmailCodeFlowProviding { + func emailCodeFlowState() -> EmailCodeFlow.State { + emailCodeState + } +} diff --git a/Friendly/Sources/AuthBindings/EmailCodeFlow.swift b/Friendly/Sources/AuthBindings/EmailCodeFlow.swift new file mode 100644 index 0000000..8850335 --- /dev/null +++ b/Friendly/Sources/AuthBindings/EmailCodeFlow.swift @@ -0,0 +1,153 @@ +import Foundation + +@MainActor +final class EmailCodeFlow { + struct State { + let email: String + let code: String + let isSendingCode: Bool + let isConfirmingCode: Bool + let remainingSeconds: Int + let isEmailLocked: Bool + let canRequestCode: Bool + let isEmailValid: Bool + let canConfirm: Bool + let formattedTimer: String + } + + enum Error: Swift.Error { + case invalidEmail + case invalidCode + } + + var email: String = "" { + didSet { notifyStateChanged() } + } + var code: String = "" { + didSet { notifyStateChanged() } + } + var isSendingCode: Bool = false { + didSet { notifyStateChanged() } + } + var isConfirmingCode: Bool = false { + didSet { notifyStateChanged() } + } + var remainingSeconds: Int = 0 { + didSet { notifyStateChanged() } + } + private(set) var requestedEmail: String? { + didSet { notifyStateChanged() } + } + + var isEmailLocked: Bool { + requestedEmail != nil || isSendingCode + } + + var canRequestCode: Bool { + remainingSeconds == 0 && !isSendingCode && !isConfirmingCode + } + + var isEmailValid: Bool { + let normalized = normalizedEmail + guard let emailRegex else { return false } + return normalized.count <= 2048 && + (try? emailRegex.wholeMatch(in: normalized)) != nil + } + + var canConfirm: Bool { + requestedEmail != nil && + !isSendingCode && + !isConfirmingCode && + ConfirmationCode(code) != nil + } + + var formattedTimer: String { + Duration.seconds(remainingSeconds) + .formatted(.time(pattern: .minuteSecond)) + } + + func state() -> State { + State( + email: email, + code: code, + isSendingCode: isSendingCode, + isConfirmingCode: isConfirmingCode, + remainingSeconds: remainingSeconds, + isEmailLocked: isEmailLocked, + canRequestCode: canRequestCode, + isEmailValid: isEmailValid, + canConfirm: canConfirm, + formattedTimer: formattedTimer, + ) + } + + private var cooldownTask: Task? + private var stateChanged: ((State) -> Void)? + private let emailRegex = + try? Regex("[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}") + + private var normalizedEmail: String { + email.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func observeState(_ observer: @escaping (State) -> Void) { + stateChanged = observer + observer(state()) + } + + func updateCode(_ value: String) { + code = ConfirmationCode.sanitize(value) + } + + func requestEmail() throws -> String { + if let requestedEmail { + return requestedEmail + } + let normalized = normalizedEmail + guard isEmailValid else { + throw Error.invalidEmail + } + email = normalized + return normalized + } + + func codeToConfirm() throws -> ConfirmationCode { + guard let code = ConfirmationCode(code) else { + throw Error.invalidCode + } + return code + } + + func didRequestCode(for email: String) { + requestedEmail = email + startCooldown() + } + + func resetEmailRequest() { + cooldownTask?.cancel() + requestedEmail = nil + remainingSeconds = 0 + code = "" + } + + func cancelCooldown() { + cooldownTask?.cancel() + } + + private func startCooldown() { + cooldownTask?.cancel() + cooldownTask = Task { [weak self] in + self?.remainingSeconds = 60 + while let remainingSeconds = self?.remainingSeconds, + remainingSeconds > 0 { + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + self?.remainingSeconds = remainingSeconds - 1 + } + } + } + + private func notifyStateChanged() { + stateChanged?(state()) + } +} diff --git a/Friendly/Sources/AuthBindings/EmailCodeFlowProviding.swift b/Friendly/Sources/AuthBindings/EmailCodeFlowProviding.swift new file mode 100644 index 0000000..3a5d0d4 --- /dev/null +++ b/Friendly/Sources/AuthBindings/EmailCodeFlowProviding.swift @@ -0,0 +1,39 @@ +@MainActor +protocol EmailCodeFlowProviding: AnyObject { + func emailCodeFlowState() -> EmailCodeFlow.State + var canConfirm: Bool { get } +} + +extension EmailCodeFlowProviding { + var code: String { + emailCodeFlowState().code + } + + var isSendingCode: Bool { + emailCodeFlowState().isSendingCode + } + + var isConfirmingCode: Bool { + emailCodeFlowState().isConfirmingCode + } + + var remainingSeconds: Int { + emailCodeFlowState().remainingSeconds + } + + var isEmailLocked: Bool { + emailCodeFlowState().isEmailLocked + } + + var canRequestCode: Bool { + emailCodeFlowState().canRequestCode + } + + var canConfirm: Bool { + emailCodeFlowState().canConfirm + } + + var formattedTimer: String { + emailCodeFlowState().formattedTimer + } +} diff --git a/Friendly/Sources/AuthBindings/EmailLoginView.swift b/Friendly/Sources/AuthBindings/EmailLoginView.swift new file mode 100644 index 0000000..e4da063 --- /dev/null +++ b/Friendly/Sources/AuthBindings/EmailLoginView.swift @@ -0,0 +1,184 @@ +import SwiftUI + +struct EmailLoginView: View { + let onSuccess: () -> Void + + @State private var viewModel = EmailLoginViewModel() + @FocusState private var isCodeInputFocused: Bool + + var body: some View { + ScrollView { + contentView + .padding() + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationTitle(String(localized: .emailLoginNavigationTitle)) + .navigationBarTitleDisplayMode(.inline) + .onDisappear { + viewModel.cancelTasks() + } + } + + private var contentView: some View { + VStack(alignment: .leading, spacing: 16) { + titleLabel + subtitleLabel + detailsLabel + emailTextField + changeEmailButton + sendCodeButton + resendTimerView + verificationCodeInputView + confirmCodeButton + statusMessageView + } + } + + private var titleLabel: some View { + Text(.emailLoginTitle) + .font(.title3) + .fontWeight(.semibold) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + + private var subtitleLabel: some View { + Text(.emailLoginSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + + private var detailsLabel: some View { + Text(.emailLoginDetails) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } + + private var emailTextField: some View { + TextField( + String(localized: .profileEditBindEmailEmailExample), + text: Binding( + get: { viewModel.email }, + set: { viewModel.email = $0 } + ) + ) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .disabled(viewModel.isEmailLocked) + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + @ViewBuilder + private var changeEmailButton: some View { + if viewModel.isEmailLocked { + Button { + viewModel.resetEmailRequest() + } label: { + Text(.emailChangeAddress) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + + private var sendCodeButton: some View { + Button(action: { viewModel.requestCode() }) { + sendCodeButtonLabel + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canRequestCode) + } + + @ViewBuilder + private var sendCodeButtonLabel: some View { + if viewModel.isSendingCode { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } else { + Text(.profileEditBindEmailSendCode) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + } + + @ViewBuilder + private var resendTimerView: some View { + if viewModel.remainingSeconds > 0 { + Text(.emailBindingResendAfter) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + Text(verbatim: viewModel.formattedTimer) + .font(.headline.monospacedDigit()) + .frame(maxWidth: .infinity, alignment: .center) + } + } + + private var verificationCodeInputView: some View { + VerificationCodeInputView( + code: Binding( + get: { viewModel.code }, + set: { viewModel.updateCode($0) } + ), + isFocused: $isCodeInputFocused, + isError: viewModel.status == .invalidCode, + ) + } + + private var confirmCodeButton: some View { + Button(action: { + Task { + try await viewModel.confirmCode() + onSuccess() + } + }) { + confirmCodeButtonLabel + } + .buttonStyle(.borderedProminent) + .disabled(!viewModel.canConfirm) + } + + @ViewBuilder + private var confirmCodeButtonLabel: some View { + if viewModel.isConfirmingCode { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } else { + Text(.emailLoginConfirm) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + } + + @ViewBuilder + private var statusMessageView: some View { + switch viewModel.status { + case .idle: + EmptyView() + case .success: + Text(.emailLoginSuccess) + .font(.footnote) + .foregroundStyle(.green) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + case .invalidEmail: + InlineErrorView(.emailErrorInvalid) + case .unknownEmail: + InlineErrorView(.emailLoginErrorUnknown) + case .invalidCode: + InlineErrorView(.emailErrorInvalidCode) + case .networkError: + InlineErrorView(.profileEditBindEmailError) + } + } +} diff --git a/Friendly/Sources/AuthBindings/EmailLoginViewModel.swift b/Friendly/Sources/AuthBindings/EmailLoginViewModel.swift new file mode 100644 index 0000000..dd52518 --- /dev/null +++ b/Friendly/Sources/AuthBindings/EmailLoginViewModel.swift @@ -0,0 +1,163 @@ +import Foundation + +@MainActor +@Observable +class EmailLoginViewModel { + private let worker = EmailAuthWorker() + private let storage: Storage = .shared + private let emailCodeFlow: EmailCodeFlow + private var emailCodeState: EmailCodeFlow.State + + var email: String { + get { emailCodeState.email } + set { + emailCodeFlow.email = newValue + clearStatus() + } + } + + var canConfirm: Bool { + emailCodeState.isEmailValid && + !emailCodeState.isSendingCode && + !emailCodeState.isConfirmingCode && + ConfirmationCode(emailCodeState.code) != nil + } + + var status: Status = .idle + + private var sendTask: Task? + private var confirmTask: Task? + + init() { + let emailCodeFlow = EmailCodeFlow() + self.emailCodeFlow = emailCodeFlow + self.emailCodeState = emailCodeFlow.state() + emailCodeFlow.observeState { [weak self] state in + self?.emailCodeState = state + } + } + + func updateCode(_ value: String) { + emailCodeFlow.updateCode(value) + clearStatus() + } + + func requestCode() { + guard emailCodeFlow.canRequestCode else { return } + sendTask?.cancel() + let emailCodeFlow = emailCodeFlow + let worker = worker + sendTask = Task { [weak self] in + self?.status = .idle + emailCodeFlow.isSendingCode = true + defer { emailCodeFlow.isSendingCode = false } + + do { + let email = try emailCodeFlow.requestEmail() + try await worker.requestLoginCode(email: email) + emailCodeFlow.didRequestCode(for: email) + } catch is CancellationError { + return + } catch NetworkClient.AuthEmailError.unknownEmail { + self?.status = .unknownEmail + } catch EmailCodeFlow.Error.invalidEmail { + self?.status = .invalidEmail + } catch { + self?.status = .networkError + } + } + } + + func confirmCode() async throws { + guard canConfirm else { + throw EmailCodeFlow.Error.invalidCode + } + confirmTask?.cancel() + let emailCodeFlow = emailCodeFlow + let worker = worker + let storage = storage + status = .idle + emailCodeFlow.isConfirmingCode = true + defer { + emailCodeFlow.isConfirmingCode = false + confirmTask = nil + } + + do { + let email = try emailCodeFlow.requestEmail() + let code = try emailCodeFlow.codeToConfirm() + let task: Task = Task { + try await worker.login( + email: email, + code: code.int, + ) + } + confirmTask = task + + let authorization = try await withTaskCancellationHandler { + try await task.value + } onCancel: { + task.cancel() + } + try Task.checkCancellation() + guard !task.isCancelled else { + throw CancellationError() + } + storage.clearAuthorization() + try storage.saveAuthorization(authorization) + status = .success + } catch { + updateStatus(for: error) + throw error + } + } + + func resetEmailRequest() { + sendTask?.cancel() + confirmTask?.cancel() + emailCodeFlow.resetEmailRequest() + status = .idle + } + + func cancelTasks() { + sendTask?.cancel() + confirmTask?.cancel() + emailCodeFlow.cancelCooldown() + } + + private func clearStatus() { + if status != .idle { + status = .idle + } + } + + private func updateStatus(for error: Swift.Error) { + switch error { + case is CancellationError: + break + case NetworkClient.AuthLoginError.invalidOrExpiredCode: + status = .invalidCode + case EmailCodeFlow.Error.invalidEmail: + status = .invalidEmail + case EmailCodeFlow.Error.invalidCode: + status = .invalidCode + default: + status = .networkError + } + } + + enum Status { + case idle + case success + case invalidEmail + case unknownEmail + case invalidCode + case networkError + } +} + +extension EmailLoginViewModel: EmailCodeFlowProviding { + func emailCodeFlowState() -> EmailCodeFlow.State { + emailCodeState + } +} diff --git a/Friendly/Sources/AuthBindings/InlineErrorView.swift b/Friendly/Sources/AuthBindings/InlineErrorView.swift new file mode 100644 index 0000000..d04f9c0 --- /dev/null +++ b/Friendly/Sources/AuthBindings/InlineErrorView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct InlineErrorView: View { + let resource: LocalizedStringResource + + init(_ resource: LocalizedStringResource) { + self.resource = resource + } + + var body: some View { + Text(resource) + .font(.footnote) + .foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .center) + .multilineTextAlignment(.center) + } +} diff --git a/Friendly/Sources/AuthBindings/VerificationCodeInputView.swift b/Friendly/Sources/AuthBindings/VerificationCodeInputView.swift new file mode 100644 index 0000000..f73b035 --- /dev/null +++ b/Friendly/Sources/AuthBindings/VerificationCodeInputView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +struct VerificationCodeInputView: View { + @Binding var code: String + @FocusState.Binding var isFocused: Bool + var isError: Bool = false + + private var digits: [String] { + Array(code.filter(\.isNumber).prefix(8)).map(String.init) + } + + var body: some View { + ZStack { + codeCellsView + hiddenCodeTextField + } + .contentShape(Rectangle()) + .onTapGesture { + isFocused = true + } + .frame(maxWidth: .infinity) + } + + private var codeCellsView: some View { + HStack(spacing: 4) { + leadingCodeCells + codeSeparatorLabel + trailingCodeCells + } + } + + private var leadingCodeCells: some View { + ForEach(0..<4, id: \.self) { index in + codeCell(at: index) + } + } + + private var codeSeparatorLabel: some View { + Text(verbatim: "-") + .font(.headline) + .foregroundStyle(.secondary) + .frame(width: 12) + } + + private var trailingCodeCells: some View { + ForEach(4..<8, id: \.self) { index in + codeCell(at: index) + } + } + + private var hiddenCodeTextField: some View { + TextField("", text: $code) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + .focused($isFocused) + .opacity(0.01) + .frame(width: 1, height: 1) + } + + private func codeCell(at index: Int) -> some View { + VerificationCodeCell( + digit: digit(at: index), + isFocused: isFocused && index == digits.count, + isError: isError, + ) + } + + private func digit(at index: Int) -> String { + guard index < digits.count else { return "" } + return digits[index] + } +} + +private struct VerificationCodeCell: View { + let digit: String + let isFocused: Bool + let isError: Bool + + var body: some View { + Text(digit) + .font(.title3.monospacedDigit()) + .frame(maxWidth: 36, minHeight: 52) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay { + RoundedRectangle(cornerRadius: 10) + .stroke( + isError + ? Color.red + : Color.accentColor.opacity(isFocused ? 1 : 0.35), + lineWidth: isFocused ? 2 : 1, + ) + } + } +} diff --git a/Friendly/Sources/ContentView.swift b/Friendly/Sources/ContentView.swift index 78b52db..a9c2540 100644 --- a/Friendly/Sources/ContentView.swift +++ b/Friendly/Sources/ContentView.swift @@ -4,31 +4,42 @@ struct ContentView: View { @State private var viewModel: ContentViewModel = ContentViewModel() 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 { + case let .addFriend(id, token): + viewModel.onAddFriend(id: id, token: token) + } + } + } + + private var destinationView: some View { ZStack { switch viewModel.destination { case .empty: EmptyView() case .signUp: - SignUpView(onComplete: viewModel.onSignUp) + SignUpView( + onSignUp: viewModel.onSignUp, + onEmailLogin: viewModel.onEmailLogin, + ) case .main: MainView( routeToSignUp: viewModel.routeToSignUp, addFriend: $viewModel.addFriend, ) case .qrAddFriend: - ScanToUseAppView(isBlocked: true) { viewModel.onAddFriendWithQr() } - } - } - .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 { - case let .addFriend(id, token): - viewModel.onAddFriend(id: id, token: token) + ScanToUseAppView( + isBlocked: true, + onEmailLogin: viewModel.onEmailLogin, + onSuccess: viewModel.onAddFriendWithQr, + ) } } } diff --git a/Friendly/Sources/ContentViewModel.swift b/Friendly/Sources/ContentViewModel.swift index a423ba3..87853f7 100644 --- a/Friendly/Sources/ContentViewModel.swift +++ b/Friendly/Sources/ContentViewModel.swift @@ -10,11 +10,9 @@ class ContentViewModel { func appear() { do { if try storage.hasAuthorization() { - if try storage.getHasFriend() { - destination = .main - } else { - destination = .qrAddFriend - } + destination = try storage.getHasFriend() + ? .main + : .qrAddFriend } else { destination = .signUp } @@ -33,6 +31,11 @@ class ContentViewModel { destination = .main } + func onEmailLogin() { + try? storage.addFriend() + destination = .main + } + func routeToSignUp() { appear() } diff --git a/Friendly/Sources/LocaleRepository.swift b/Friendly/Sources/LocaleRepository.swift new file mode 100644 index 0000000..807a9f1 --- /dev/null +++ b/Friendly/Sources/LocaleRepository.swift @@ -0,0 +1,17 @@ +import Foundation + +struct LocaleRepository { + func obtain() -> LocaleCode { + let identifier = + Bundle.main.preferredLocalizations.first ?? Locale.current.identifier + let languageCode = + Locale(identifier: identifier).language.languageCode?.identifier + ?? LocaleCode.en.rawValue + return LocaleCode(rawValue: languageCode) ?? .en + } +} + +enum LocaleCode: String { + case en + case ru +} diff --git a/Friendly/Sources/Localizable.xcstrings b/Friendly/Sources/Localizable.xcstrings index 8a098b2..761ffae 100644 --- a/Friendly/Sources/Localizable.xcstrings +++ b/Friendly/Sources/Localizable.xcstrings @@ -18,390 +18,1002 @@ } } }, + "auth_bindings_apple_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue with Apple" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить с Apple" + } + } + } + }, + "auth_bindings_google_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue with Google" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Продолжить с Google" + } + } + } + }, + "auth_bindings_navigation_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Authorization" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Авторизация" + } + } + } + }, + "auth_bindings_provider_unavailable_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This authorization method is not available yet." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Этот способ авторизации пока недоступен." + } + } + } + }, + "auth_bindings_provider_unavailable_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Coming soon" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скоро будет" + } + } + } + }, + "auth_bindings_subtitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a method to link your account." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выберите способ привязки аккаунта." + } + } + } + }, + "auth_bindings_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Link authorization" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Привязка авторизации" + } + } + } + }, "button_base_close" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Close" + "value" : "Close" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрыть" + } + } + } + }, + "email_binding_error_used" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This email is already linked to another account." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Эта почта уже привязана к другому аккаунту." + } + } + } + }, + "email_binding_details" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The code is valid for 20 minutes and up to 5 confirmation attempts." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Код действителен 20 минут, доступно до 5 попыток подтверждения." + } + } + } + }, + "email_binding_resend_after" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can resend the code after:" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторно отправить код можно через:" + } + } + } + }, + "email_change_address" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use another email" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Использовать другую почту" + } + } + } + }, + "email_error_invalid" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter a valid email address." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите корректный адрес почты." + } + } + } + }, + "email_error_invalid_code" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The code is invalid or has expired." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Код неверный или срок его действия истёк." + } + } + } + }, + "email_error_unauthorized" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your session has expired. Sign in again." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сессия истекла. Войдите снова." + } + } + } + }, + "email_login_confirm" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Войти" + } + } + } + }, + "email_login_error_unknown" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No account is linked to this email." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "К этой почте не привязан аккаунт." + } + } + } + }, + "email_login_details" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter the code from email to sign in to an existing account." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Введите код из почты, чтобы войти в существующий аккаунт." + } + } + } + }, + "email_login_navigation_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in by email" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вход по почте" + } + } + } + }, + "email_login_subtitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use your email to sign in" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используйте почту для входа" + } + } + } + }, + "email_login_success" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signed in successfully." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вы успешно вошли." + } + } + } + }, + "email_login_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in to your account" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Вход в аккаунт" + } + } + } + }, + "error_base_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unable to verify the data. Please try again later." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось проверить данные. Попробуйте ещё раз позже." + } + } + } + }, + "error_base_subtite" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try again" + } + }, + "ru" : { + "stringUnit" : { + "state" : "needs_review", + "value" : "Повторите еще" + } + } + } + }, + "error_base_title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Error" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ошибка" + } + } + } + }, + "feed_empty" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You're all caught up!" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Больше тут никого нет" + } + } + } + }, + "feed_empty_advice" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Now you can talk to the people you've met" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Теперь время общаться с новыми знакомыми!" + } + } + } + }, + "feed_expand" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Expand" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Раскрыть" + } + } + } + }, + "feed_extended_network" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "EXTENDED NETWORK" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ДАЛЬНЕЕ РУКОПОЖАТИЕ" + } + } + } + }, + "feed_liked_you" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "FRIEND REQUEST" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ЗАПРОС В ДРУЗЬЯ" + } + } + } + }, + "io_error_subtitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Looks like we can't reach our servers..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Мы не можем достучаться до наших серверов…" + } + } + } + }, + "io_error_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection problem" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проблема соединения" + } + } + } + }, + "main_feed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Feed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Лента" + } + } + } + }, + "main_network" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Network" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Закрыть" + "value" : "Сообщество" } } } }, - "error_base_message" : { + "main_profile" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unable to verify the data. Please try again later." + "value" : "Profile" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Не удалось проверить данные. Попробуйте ещё раз позже." + "value" : "Профиль" } } } }, - "error_base_subtite" : { + "network_friends_add_hint" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Try again" + "value" : "Add more friends to expand your network!" } }, "ru" : { "stringUnit" : { - "state" : "needs_review", - "value" : "Повторите еще" + "state" : "translated", + "value" : "Добавляй друзей, чтобы расширить сообщество" } } } }, - "error_base_title" : { + "network_friends_empty" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Error" + "value" : "No one is here" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Ошибка" + "value" : "Пока здесь пусто" } } } }, - "feed_empty" : { + "network_friends_scan_qrcode" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "You're all caught up!" + "value" : "Add friends by sharing your QR Code" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Больше тут никого нет" + "value" : "Используй QR-код, чтобы добавить друзей" } } } }, - "feed_empty_advice" : { + "network_friends_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Now you can talk to the people you've met" + "value" : "Friends" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Теперь время общаться с новыми знакомыми!" + "value" : "Друзья" } } } }, - "feed_expand" : { + "network_qrcode_description" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Expand" + "value" : "This is a single-time QR Code that may be used to add you as a friend" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Раскрыть" + "value" : "Это одноразовый QR-код, который может быть использован, чтобы добавить вас в друзья" } } } }, - "feed_extended_network" : { + "network_qrcode_link" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "EXTENDED NETWORK" + "value" : "Or share a link" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "ДАЛЬНЕЕ РУКОПОЖАТИЕ" + "value" : "Или поделись ссылкой" } } } }, - "feed_liked_you" : { + "network_qrcode_ok" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "FRIEND REQUEST" + "value" : "OK" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "ЗАПРОС В ДРУЗЬЯ" + "value" : "Ок" } } } }, - "io_error_subtitle" : { + "network_qrcode_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Looks like we can't reach our servers..." + "value" : "Add people" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Мы не можем достучаться до наших серверов…" + "value" : "Добавить друзей" } } } }, - "io_error_title" : { + "profile_edit" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Connection problem" + "value" : "Edit" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Проблема соединения" + "value" : "Редактирование" } } } }, - "main_feed" : { + "profile_edit_auth_bindings_button" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Feed" + "value" : "Authorization methods" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Лента" + "value" : "Способы авторизации" } } } }, - "main_network" : { + "profile_edit_bind_email_button" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Network" + "value" : "Bind email" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Сообщество" + "value" : "Привязать почту" } } } }, - "main_profile" : { + "profile_edit_bind_email_code_hint" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Profile" + "value" : "Code format: 7389 - 9399" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Профиль" + "value" : "Формат кода: 7389 - 9399" } } } }, - "network_friends_add_hint" : { + "profile_edit_bind_email_code_placeholder" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Add more friends to expand your network!" + "value" : "Verification code" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Добавляй друзей, чтобы расширить сообщество" + "value" : "Код подтверждения" } } } }, - "network_friends_empty" : { + "profile_edit_bind_email_confirm" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "No one is here" + "value" : "Confirm" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Пока здесь пусто" + "value" : "Подтвердить" } } } }, - "network_friends_scan_qrcode" : { + "profile_edit_bind_email_email_example" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Add friends by sharing your QR Code" + "value" : "jane.doe@example.com" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Используй QR-код, чтобы добавить друзей" + "value" : "ivan.ivanov@example.com" } } } }, - "network_friends_title" : { + "profile_edit_bind_email_email_label" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Friends" + "value" : "Email" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Друзья" + "value" : "Почта" } } } }, - "network_qrcode_description" : { + "profile_edit_bind_email_email_placeholder" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "This is a single-time QR Code that may be used to add you as a friend" + "value" : "Email" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Это одноразовый QR-код, который может быть использован, чтобы добавить вас в друзья" + "value" : "Почта" } } } }, - "network_qrcode_link" : { + "profile_edit_bind_email_error" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Or share a link" + "value" : "Something went wrong. Please try again." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Или поделись ссылкой" + "value" : "Что-то пошло не так. Попробуйте ещё раз." } } } }, - "network_qrcode_ok" : { + "profile_edit_bind_email_resend_in" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "Resend in" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Ок" + "value" : "Повторно через" } } } }, - "network_qrcode_title" : { + "profile_edit_bind_email_send_code" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Add people" + "value" : "Send code" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Добавить друзей" + "value" : "Отправить код" } } } }, - "profile_edit" : { + "profile_edit_bind_email_sheet_title" : { "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Edit" + "value" : "Email binding" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Редактирование" + "value" : "Привязка почты" + } + } + } + }, + "profile_edit_bind_email_subtitle" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Request a code and confirm email ownership." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Запросите код и подтвердите владение почтой." + } + } + } + }, + "profile_edit_bind_email_success" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Email has been successfully linked." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Почта успешно привязана." + } + } + } + }, + "profile_edit_bind_email_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bind your email" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Привяжите почту" } } } @@ -423,6 +1035,74 @@ } } }, + "profile_edit_email_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Email" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Почта" + } + } + } + }, + "profile_edit_unlink_email_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlink email" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отвязать почту" + } + } + } + }, + "profile_edit_unlink_email_confirm" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlink" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отвязать" + } + } + } + }, + "profile_edit_unlink_email_confirmation" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Without a linked email, you may not be able to sign back in to this account." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Без привязанной почты вы можете потерять возможность снова войти в этот аккаунт." + } + } + } + }, "profile_error" : { "extractionState" : "manual", "localizations" : { @@ -559,6 +1239,40 @@ } } }, + "scan_enter_blocked_email_description" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If you already have an account, you can sign in by email." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Если у вас уже есть аккаунт, вы можете войти по почте." + } + } + } + }, + "scan_enter_blocked_email_login_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in by email" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Войти по почте" + } + } + } + }, "scan_enter_error_alert_button_cancel" : { "extractionState" : "manual", "localizations" : { @@ -611,6 +1325,7 @@ } }, "scan_enter_info_subtitle" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -627,6 +1342,7 @@ } }, "scan_enter_info_title" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -643,6 +1359,7 @@ } }, "scan_enter_open_photo_scanner" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -659,6 +1376,7 @@ } }, "scan_enter_open_scanner" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -692,6 +1410,7 @@ } }, "scan_enter_scanning_subtitle" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -708,6 +1427,7 @@ } }, "scan_enter_scanning_title" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -756,6 +1476,7 @@ } }, "scanner_qrcode_navigation_title" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -939,6 +1660,23 @@ } } }, + "sign_up_email_login" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in by email" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Войти по почте" + } + } + } + }, "sign_up_sign_up" : { "extractionState" : "manual", "localizations" : { @@ -1026,4 +1764,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Friendly/Sources/NetworkClient.swift b/Friendly/Sources/NetworkClient.swift index 04edc2b..f3eb812 100644 --- a/Friendly/Sources/NetworkClient.swift +++ b/Friendly/Sources/NetworkClient.swift @@ -4,6 +4,7 @@ import SwiftUI // todo: move to a separate swift module class NetworkClient { private let transport: Transport + private let localeRepository: LocaleRepository let baseUrl: URL let landingUrl: URL @@ -11,10 +12,12 @@ class NetworkClient { baseUrl: URL, landingUrl: URL, session: URLSession = .shared, + localeRepository: LocaleRepository = LocaleRepository(), ) { self.baseUrl = baseUrl self.landingUrl = landingUrl self.transport = Transport(baseUrl: baseUrl, session: session) + self.localeRepository = localeRepository } enum AuthGenerateError: Error { @@ -72,6 +75,85 @@ class NetworkClient { let accessHash: String } + enum AuthEmailError: Error { + case ioError(Error) + case serverError + case unknownEmail + } + + func authEmail(email: String) async throws(AuthEmailError) { + do { + let body = AuthEmailRequestBody(email: email) + try await transport.unauthorizedVoid( + path: "auth/email", + method: .post, + body: body, + headers: localeHeaders, + ) + } catch let error { + switch error { + case .ioError(let error): throw .ioError(error) + case .serverError(let statusCode, _): + if statusCode == 401 { + throw .unknownEmail + } + throw .serverError + } + } + } + + private struct AuthEmailRequestBody: Encodable { + let email: String + } + + enum AuthLoginError: Error { + case ioError(Error) + case serverError + case invalidOrExpiredCode + } + + func authLogin( + email: String, + code: Int, + ) async throws(AuthLoginError) -> Authorization { + do { + let body = AuthLoginRequestBody(email: email, code: code) + let response = try await transport.unauthorized( + path: "auth/login", + method: .post, + body: body, + type: AuthLoginResponseBody.self, + ) + return Authorization( + token: try Token(response.token), + id: UserId(response.id), + accessHash: try UserAccessHash(response.accessHash), + ) + } catch let error as Transport.UnauthorizedError { + switch error { + case .ioError(let error): throw .ioError(error) + case .serverError(let statusCode, _): + if statusCode == 403 { + throw .invalidOrExpiredCode + } + throw .serverError + } + } catch { + throw .serverError + } + } + + private struct AuthLoginRequestBody: Encodable { + let email: String + let code: Int + } + + private struct AuthLoginResponseBody: Decodable { + let token: String + let id: Int64 + let accessHash: String + } + enum UserDetailsError: Error { case ioError(Error) case serverError @@ -136,6 +218,115 @@ class NetworkClient { } } + enum EmailLinkError: Error { + case ioError(Error) + case serverError + case unauthorized + case alreadyUsed + } + + func emailLink( + authorization: Authorization, + email: String, + ) async throws(EmailLinkError) { + do { + let body = EmailLinkRequestBody(email: email) + try await transport.authorizedVoid( + path: "email/link", + method: .post, + body: body, + authorization: authorization, + headers: localeHeaders, + ) + } catch let error { + switch error { + case .ioError(let error): + throw .ioError(error) + case .unauthorized: + throw .unauthorized + case .serverError(let statusCode, _): + if statusCode == 409 { + throw .alreadyUsed + } + throw .serverError + } + } + } + + private struct EmailLinkRequestBody: Encodable { + let email: String + } + + private var localeHeaders: [String: String] { + ["X-Locale": localeRepository.obtain().rawValue] + } + + enum EmailConfirmError: Error { + case ioError(Error) + case serverError + case unauthorized + case invalidOrExpiredCode + } + + func emailConfirm( + authorization: Authorization, + code: Int, + ) async throws(EmailConfirmError) { + do { + let body = EmailConfirmRequestBody(code: code) + try await transport.authorizedVoid( + path: "email/confirm", + method: .post, + body: body, + authorization: authorization, + ) + } catch let error { + switch error { + case .ioError(let error): + throw .ioError(error) + case .unauthorized: + throw .unauthorized + case .serverError(let statusCode, _): + if statusCode == 403 { + throw .invalidOrExpiredCode + } + throw .serverError + } + } + } + + private struct EmailConfirmRequestBody: Encodable { + let code: Int + } + + enum EmailUnlinkError: Error { + case ioError(Error) + case serverError + case unauthorized + } + + func emailUnlink( + authorization: Authorization, + ) async throws(EmailUnlinkError) { + do { + try await transport.authorizedVoid( + path: "email/unlink", + method: .post, + body: nil, + authorization: authorization, + ) + } catch let error { + switch error { + case .ioError(let error): + throw .ioError(error) + case .unauthorized: + throw .unauthorized + case .serverError: + throw .serverError + } + } + } + enum NetworkDetailsError: Error { case ioError(Error) case serverError @@ -388,4 +579,3 @@ class NetworkClient { )!, ) } - diff --git a/Friendly/Sources/ProfileEdit/Models/ProfileInfo.swift b/Friendly/Sources/ProfileEdit/Models/ProfileInfo.swift index f0be107..c9fe550 100644 --- a/Friendly/Sources/ProfileEdit/Models/ProfileInfo.swift +++ b/Friendly/Sources/ProfileEdit/Models/ProfileInfo.swift @@ -13,4 +13,5 @@ struct ProfileInfo { let description: UserDescription let interests: [Interest] let socialUrl: URL? + let email: String? } diff --git a/Friendly/Sources/ProfileEdit/ProfileEditView.swift b/Friendly/Sources/ProfileEdit/ProfileEditView.swift index fe53821..0aad6a0 100644 --- a/Friendly/Sources/ProfileEdit/ProfileEditView.swift +++ b/Friendly/Sources/ProfileEdit/ProfileEditView.swift @@ -24,6 +24,7 @@ struct ProfileEditView: View { } var body: some View { + @Bindable var viewModel = viewModel NavigationView { ScrollView { AvatarPicker(viewModel: viewModel) @@ -32,11 +33,12 @@ struct ProfileEditView: View { description: $viewModel.description, socialLink: $viewModel.socialLink, ) + emailSectionView Interests(viewModel: viewModel) } .toolbar { ToolbarItem(placement: .principal) { - Text("profile_edit") + Text(.profileEdit) } ToolbarItemGroup(placement: .primaryAction) { Button(action: { viewModel.dismiss() }) { @@ -52,24 +54,24 @@ struct ProfileEditView: View { .padding(.vertical, 12) } .alert( - "sign_up_error", + String(localized: .signUpError), isPresented: .constant(viewModel.error != nil), ) { - Button("sign_up_error_ok") { + Button(String(localized: .signUpErrorOk)) { viewModel.clearError() } .keyboardShortcut(.defaultAction) } message: { if let error = viewModel.error { - let string: LocalizedStringKey = switch error { - case .required: "sign_up_required_fields" - case .nicknameMaxLength: "sign_up_nickname_max_length" - case .descriptionMaxLength: "sign_up_description_max_length" - case .socialLinkMaxLength: "sign_up_social_link_max_length" - case .socialLinkNotUrl: "sign_up_social_link_not_url" - case .ioError: "sign_up_io_error" + let resource: LocalizedStringResource = switch error { + case .required: .signUpRequiredFields + case .nicknameMaxLength: .signUpNicknameMaxLength + case .descriptionMaxLength: .signUpDescriptionMaxLength + case .socialLinkMaxLength: .signUpSocialLinkMaxLength + case .socialLinkNotUrl: .signUpSocialLinkNotUrl + case .ioError: .signUpIoError } - Text(string) + Text(resource) } } } @@ -77,6 +79,82 @@ struct ProfileEditView: View { viewModel.cancelTasks() } } + + @ViewBuilder + private var emailSectionView: some View { + if let email = viewModel.email { + EmailInfoView( + email: email, + isUnlinking: viewModel.isUnlinkingEmail, + onUnlink: viewModel.unlinkEmail, + ) + .padding(.horizontal) + } else { + AuthBindingsNavigationButton { email in + viewModel.emailLinked(email) + viewModel.dismiss() + } + .padding(.horizontal) + } + } +} + +private struct EmailInfoView: View { + let email: String + let isUnlinking: Bool + let onUnlink: () -> Void + + @State private var showUnlinkConfirmation = false + + var body: some View { + HStack { + emailLabelsView + Spacer() + unlinkButton + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.top, 8) + .confirmationDialog( + String(localized: .profileEditUnlinkEmailConfirmation), + isPresented: $showUnlinkConfirmation, + titleVisibility: .visible, + ) { + Button( + String(localized: .profileEditUnlinkEmailConfirm), + role: .destructive, + action: onUnlink, + ) + } + } + + private var emailLabelsView: some View { + VStack(alignment: .leading, spacing: 6) { + Text(.profileEditEmailTitle) + .font(.caption) + .foregroundStyle(.secondary) + Text(email) + .font(.body) + } + } + + @ViewBuilder + private var unlinkButton: some View { + if isUnlinking { + ProgressView() + } else { + Button(role: .destructive) { + showUnlinkConfirmation = true + } label: { + Image(systemName: "link.badge.minus") + } + .accessibilityLabel( + String(localized: .profileEditUnlinkEmailButton) + ) + } + } } private struct AvatarPicker: View { @@ -119,7 +197,7 @@ private struct AvatarPicker: View { } PhotosPicker(selection: $selectedItem, matching: .images) { - Text("sign_up_upload") + Text(.signUpUpload) .padding(.vertical, 5) .padding(.horizontal, 20) } @@ -160,7 +238,10 @@ private struct Inputs: View { Image(systemName: "person") .foregroundColor(.secondary) - TextField("sign_up_nickname", text: $nickname) + TextField( + String(localized: .signUpNickname), + text: $nickname + ) .textInputAutocapitalization(.never) .autocorrectionDisabled() } @@ -172,7 +253,7 @@ private struct Inputs: View { Image(systemName: "paperplane") .foregroundColor(.secondary) TextField( - "sign_up_social_link", + String(localized: .signUpSocialLink), text: $socialLink, axis: .vertical, ) @@ -188,7 +269,7 @@ private struct Inputs: View { Image(systemName: "bubble") .foregroundColor(.secondary) TextField( - "sign_up_description", + String(localized: .signUpDescription), text: $description, axis: .vertical, ) @@ -228,7 +309,7 @@ private struct SaveButton: View { @Bindable var viewModel = viewModel Button(action: { viewModel.clicksave() }) { ZStack { - Text("profile_edit_button_save") + Text(.profileEditButtonSave) .font(.headline) .frame(maxWidth: .infinity) .padding() @@ -243,3 +324,23 @@ private struct SaveButton: View { .disabled(viewModel.saveButtonDisabled) } } + +private struct AuthBindingsNavigationButton: View { + let onEmailLinked: (String) -> Void + + var body: some View { + NavigationLink { + AuthBindingsView(onEmailLinked: onEmailLinked) + } label: { + HStack { + Image(systemName: "envelope") + Text(.profileEditAuthBindingsButton) + } + .font(.headline) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.glass) + .padding(.top, 8) + } +} diff --git a/Friendly/Sources/ProfileEdit/ProfileEditViewModel.swift b/Friendly/Sources/ProfileEdit/ProfileEditViewModel.swift index f5a1e2e..eee2bae 100644 --- a/Friendly/Sources/ProfileEdit/ProfileEditViewModel.swift +++ b/Friendly/Sources/ProfileEdit/ProfileEditViewModel.swift @@ -24,6 +24,7 @@ class ProfileEditViewModel { var socialLink: String = "" { didSet { validate(reason: .socialLink) } } + var email: String? let interests: [Interest] = [ try! Interest("apples"), try! Interest("coding"), @@ -45,15 +46,17 @@ class ProfileEditViewModel { var clearImage: Bool = false var avatarDescriptor: FileDescriptor? = nil var loading: Bool = false + var isUnlinkingEmail: Bool = false var error: Error? = nil var profileInfo: ProfileInfo var saveButtonDisabled: Bool { - get { return uploading } + uploading || isUnlinkingEmail } private var uploadTask: Task? private var saveTask: Task? + private var unlinkEmailTask: Task? init( profileInfo: ProfileInfo, @@ -63,6 +66,7 @@ class ProfileEditViewModel { self.nickname = profileInfo.nickname.string self.description = profileInfo.description.string self.socialLink = profileInfo.socialUrl.map(\.absoluteString) ?? "" + self.email = profileInfo.email self.pickedInterests = Set(profileInfo.interests) self.onComplete = onComplete } @@ -75,6 +79,35 @@ class ProfileEditViewModel { func cancelTasks() { uploadTask?.cancel() saveTask?.cancel() + unlinkEmailTask?.cancel() + } + + func emailLinked(_ email: String) { + self.email = email + } + + func unlinkEmail() { + guard email != nil, !isUnlinkingEmail else { return } + unlinkEmailTask?.cancel() + let networkClient = networkClient + let storage = storage + unlinkEmailTask = Task { [weak self] in + self?.isUnlinkingEmail = true + defer { self?.isUnlinkingEmail = false } + + do { + let authorization = try storage.loadAuthorization() + try await networkClient.emailUnlink( + authorization: authorization, + ) + try Task.checkCancellation() + self?.email = nil + } catch is CancellationError { + return + } catch { + self?.error = .ioError + } + } } func upload(_ data: Data) { diff --git a/Friendly/Sources/ProfileViewModel.swift b/Friendly/Sources/ProfileViewModel.swift index c8df3c3..82c7422 100644 --- a/Friendly/Sources/ProfileViewModel.swift +++ b/Friendly/Sources/ProfileViewModel.swift @@ -123,6 +123,7 @@ class ProfileViewModel { description: userDetails.description, interests: userDetails.interests, socialUrl: socialUrl, + email: userDetails.email, ) state = .success(success) } catch is CancellationError { diff --git a/Friendly/Sources/ScannerQr/ScanView.swift b/Friendly/Sources/ScannerQr/ScanView.swift index 0915c19..c966dff 100644 --- a/Friendly/Sources/ScannerQr/ScanView.swift +++ b/Friendly/Sources/ScannerQr/ScanView.swift @@ -12,25 +12,25 @@ struct ScanToUseAppView: View { @StateObject private var viewModel: ScanToUseAppViewModel @State private var pickedPhotoItem: PhotosPickerItem? = nil private var isBlocked: Bool + private let onEmailLogin: (() -> Void)? @Environment(\.openURL) private var openURL @Environment(\.dismiss) private var dismiss init( isBlocked: Bool, + onEmailLogin: (() -> Void)? = nil, onSuccess: @escaping () -> Void ) { self.isBlocked = isBlocked + self.onEmailLogin = onEmailLogin _viewModel = StateObject(wrappedValue: ScanToUseAppViewModel(onSuccess: onSuccess)) } var body: some View { NavigationView { ZStack { - switch viewModel.state { - case .idle: content - case .loading: LoadingView() - } + stateView } .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -39,20 +39,27 @@ struct ScanToUseAppView: View { } } .alert( - "scan_enter_error_alert_title", + String(localized: .scanEnterErrorAlertTitle), isPresented: $viewModel.isErrorAlertPresented ) { - Button("scan_enter_error_alert_button_cancel", role: .cancel) { + Button( + String(localized: .scanEnterErrorAlertButtonCancel), + role: .cancel + ) { viewModel.tapCancelButton() } } message: { - Text( - String( - localized: LocalizedStringResource( - stringLiteral: viewModel.errorMessage ?? "error_base_message" + if let errorMessage = viewModel.errorMessage { + Text( + String( + localized: LocalizedStringResource( + stringLiteral: errorMessage + ) ) ) - ) + } else { + Text(.errorBaseMessage) + } } .sheet(isPresented: $viewModel.isScannerPresented) { QRScannerCameraView { code in @@ -78,61 +85,105 @@ struct ScanToUseAppView: View { } } - private var content: some View { + @ViewBuilder + private var stateView: some View { + switch viewModel.state { + case .idle: + contentView + case .loading: + LoadingView() + } + } + + private var contentView: some View { VStack(spacing: 20) { Spacer() + qrCodeImage + titleLabel + subtitleLabel + emailLoginSectionView + Spacer() + openScannerButton + photoPickerButton + Spacer(minLength: 24) + } + } - Image(systemName: "qrcode.viewfinder") - .font(.system(size: 56)) - .padding(.bottom, 8) + private var qrCodeImage: some View { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 56)) + .padding(.bottom, 8) + } - Text("scan_enter_info_title") - .font(.title3) - .multilineTextAlignment(.center) - .padding(.horizontal, 16) - + private var titleLabel: some View { + Text(.scanEnterInfoTitle) + .font(.title3) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + + private var subtitleLabel: some View { + Text(.scanEnterInfoSubtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } - Text("scan_enter_info_subtitle") - .font(.subheadline) + @ViewBuilder + private var emailLoginSectionView: some View { + if isBlocked, let onEmailLogin { + Text(.scanEnterBlockedEmailDescription) + .font(.footnote) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 16) - - Spacer() - - Button { - viewModel.openScanner() + NavigationLink { + EmailLoginView(onSuccess: onEmailLogin) } label: { - Text("scan_enter_open_scanner") + Text(.scanEnterBlockedEmailLoginButton) .font(.headline) .frame(maxWidth: .infinity) .padding() } - .keyboardShortcut(.defaultAction) - .buttonStyle(.glassProminent) + .buttonStyle(.borderedProminent) .padding(.horizontal) + } + } - PhotosPicker( - selection: $pickedPhotoItem, - matching: .images, - photoLibrary: .shared() - ) { - Text("scan_enter_open_photo_scanner") - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - } - .buttonStyle(.glass) - .padding(.horizontal) + private var openScannerButton: some View { + Button { + viewModel.openScanner() + } label: { + Text(.scanEnterOpenScanner) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.glassProminent) + .padding(.horizontal) + } - Spacer(minLength: 24) + private var photoPickerButton: some View { + PhotosPicker( + selection: $pickedPhotoItem, + matching: .images, + photoLibrary: .shared() + ) { + Text(.scanEnterOpenPhotoScanner) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() } + .buttonStyle(.glass) + .padding(.horizontal) } @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { ToolbarItem(placement: .principal) { - Text("scanner_qrcode_navigation_title") + Text(.scannerQrcodeNavigationTitle) } ToolbarItem(placement: .primaryAction) { Button(action: { dismiss() }) { diff --git a/Friendly/Sources/SignUpView.swift b/Friendly/Sources/SignUpView.swift index c401237..22be96d 100644 --- a/Friendly/Sources/SignUpView.swift +++ b/Friendly/Sources/SignUpView.swift @@ -5,60 +5,89 @@ import Flow struct SignUpView: View { @State private var viewModel: SignUpViewModel + private let onEmailLogin: () -> Void - init(onComplete: @escaping () -> Void) { - self.viewModel = SignUpViewModel(onComplete: onComplete) + init( + onSignUp: @escaping () -> Void, + onEmailLogin: @escaping () -> Void, + ) { + self.viewModel = SignUpViewModel(onComplete: onSignUp) + self.onEmailLogin = onEmailLogin } var body: some View { NavigationStack { - ScrollView { - AvatarPicker(viewModel: viewModel) - Inputs( - nickname: $viewModel.nickname, - description: $viewModel.description, - socialLink: $viewModel.socialLink, - ) - Interests(viewModel: viewModel) - } + contentView .scrollDismissesKeyboard(.interactively) .background(Color(uiColor: .systemGroupedBackground)) .toolbar { ToolbarItem(placement: .principal) { - Text("app_name") + Text(.appName) .font(.largeTitle) .fontWeight(.bold) .padding(.top) } } .safeAreaInset(edge: .bottom) { - SignUpButton(viewModel: viewModel) - .padding(.horizontal, 20) - .padding(.vertical, 12) + bottomControlsView + .padding(.horizontal, 20) + .padding(.vertical, 12) } .alert( - "sign_up_error", + String(localized: .signUpError), isPresented: .constant(viewModel.error != nil), ) { - Button("sign_up_error_ok") { + Button(String(localized: .signUpErrorOk)) { viewModel.clearError() } .keyboardShortcut(.defaultAction) } message: { if let error = viewModel.error { - let string: LocalizedStringKey = switch error { - case .required: "sign_up_required_fields" - case .nicknameMaxLength: "sign_up_nickname_max_length" - case .descriptionMaxLength: "sign_up_description_max_length" - case .socialLinkMaxLength: "sign_up_social_link_max_length" - case .socialLinkNotUrl: "sign_up_social_link_not_url" - case .ioError: "sign_up_io_error" + let resource: LocalizedStringResource = switch error { + case .required: .signUpRequiredFields + case .nicknameMaxLength: .signUpNicknameMaxLength + case .descriptionMaxLength: .signUpDescriptionMaxLength + case .socialLinkMaxLength: .signUpSocialLinkMaxLength + case .socialLinkNotUrl: .signUpSocialLinkNotUrl + case .ioError: .signUpIoError } - Text(string) + Text(resource) } } } } + + private var contentView: some View { + ScrollView { + AvatarPicker(viewModel: viewModel) + Inputs( + nickname: $viewModel.nickname, + description: $viewModel.description, + socialLink: $viewModel.socialLink, + ) + Interests(viewModel: viewModel) + } + } + + private var bottomControlsView: some View { + VStack(spacing: 8) { + SignUpButton(viewModel: viewModel) + emailLoginNavigationLink + } + } + + private var emailLoginNavigationLink: some View { + NavigationLink { + EmailLoginView(onSuccess: onEmailLogin) + } label: { + Text(.signUpEmailLogin) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + } + .buttonStyle(.glass) + .disabled(viewModel.loading || viewModel.uploading) + } } private struct AvatarPicker: View { @@ -101,7 +130,7 @@ private struct AvatarPicker: View { } PhotosPicker(selection: $selectedItem, matching: .images) { - Text("sign_up_upload") + Text(.signUpUpload) .padding(.vertical, 5) .padding(.horizontal, 20) } @@ -142,7 +171,10 @@ private struct Inputs: View { Image(systemName: "person") .foregroundColor(.secondary) - TextField("sign_up_nickname", text: $nickname) + TextField( + String(localized: .signUpNickname), + text: $nickname + ) .textInputAutocapitalization(.never) .autocorrectionDisabled() } @@ -154,7 +186,7 @@ private struct Inputs: View { Image(systemName: "paperplane") .foregroundColor(.secondary) TextField( - "sign_up_social_link", + String(localized: .signUpSocialLink), text: $socialLink, axis: .vertical, ) @@ -170,7 +202,7 @@ private struct Inputs: View { Image(systemName: "bubble") .foregroundColor(.secondary) TextField( - "sign_up_description", + String(localized: .signUpDescription), text: $description, axis: .vertical, ) @@ -210,7 +242,7 @@ private struct SignUpButton: View { @Bindable var viewModel = viewModel Button(action: { viewModel.clickSignUp() }) { ZStack { - Text("sign_up_sign_up") + Text(.signUpSignUp) .font(.headline) .frame(maxWidth: .infinity) .padding() diff --git a/Friendly/Sources/Transport.swift b/Friendly/Sources/Transport.swift index ae2c122..20ec40e 100644 --- a/Friendly/Sources/Transport.swift +++ b/Friendly/Sources/Transport.swift @@ -17,7 +17,7 @@ struct Transport { enum UnauthorizedError: Error { case ioError(Error) - case serverError + case serverError(statusCode: Int, body: String) } func unauthorized( @@ -44,10 +44,16 @@ struct Transport { } let (data, response) = try await session.data(for: request) guard let response = response as? HTTPURLResponse else { - throw UnauthorizedError.serverError + throw UnauthorizedError.serverError( + statusCode: 0, + body: String(decoding: data, as: UTF8.self), + ) } guard response.statusCode == 200 else { - throw UnauthorizedError.serverError + throw UnauthorizedError.serverError( + statusCode: response.statusCode, + body: String(decoding: data, as: UTF8.self), + ) } return try decoder.decode(type, from: data) } catch let error as UnauthorizedError { @@ -57,9 +63,53 @@ struct Transport { } } + func unauthorizedVoid( + path: String, + method: Method, + body: Encodable?, + headers: [String: String] = [:], + ) async throws(UnauthorizedError) { + let url = baseUrl.appending(path: path) + var request = URLRequest(url: url) + let httpMethod = switch method { + case .get: "GET" + case .post: "POST" + case .patch: "PATCH" + } + request.httpMethod = httpMethod + request.setValue( + "application/json", + forHTTPHeaderField: "Content-Type", + ) + apply(headers: headers, to: &request) + do { + if let body = body { + request.httpBody = try encoder.encode(body) + } + let (data, response) = try await session.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw UnauthorizedError.serverError( + statusCode: 0, + body: String(decoding: data, as: UTF8.self), + ) + } + guard response.statusCode == 200 else { + throw UnauthorizedError.serverError( + statusCode: response.statusCode, + body: String(decoding: data, as: UTF8.self), + ) + } + return + } catch let error as UnauthorizedError { + throw error + } catch { + throw .ioError(error) + } + } + enum AuthorizedError: Error { case ioError(Error) - case serverError(String) + case serverError(statusCode: Int, body: String) case unauthorized } @@ -82,10 +132,16 @@ struct Transport { let (data, response) = try await session.data(for: request) let string = String(decoding: data, as: UTF8.self) guard let response = response as? HTTPURLResponse else { - throw AuthorizedError.serverError("\(response): \(string)") + throw AuthorizedError.serverError(statusCode: 0, body: string) + } + if response.statusCode == 401 { + throw AuthorizedError.unauthorized } guard response.statusCode == 200 else { - throw AuthorizedError.serverError("\(response): \(string)") + throw AuthorizedError.serverError( + statusCode: response.statusCode, + body: string, + ) } return try decoder.decode(type, from: data) } catch let error as AuthorizedError { @@ -99,32 +155,33 @@ struct Transport { path: String, method: Method, body: Encodable?, - authorization: Authorization + authorization: Authorization, + headers: [String: String] = [:], ) async throws(AuthorizedError) { var request = createRequestAuthorized( path: path, method: method, authorization: authorization ) + apply(headers: headers, to: &request) do { if let body = body { request.httpBody = try encoder.encode(body) } - if let body = request.httpBody { - print(String(decoding: body, as: UTF8.self)) - } - let (data, response) = try await session.data(for: request) let string = String(decoding: data, as: UTF8.self) guard let response = response as? HTTPURLResponse else { - throw AuthorizedError.serverError("\(response): \(string)") + throw AuthorizedError.serverError(statusCode: 0, body: string) + } + if response.statusCode == 401 { + throw AuthorizedError.unauthorized } - - print("statusCode: \(response.statusCode)") guard response.statusCode == 200 else { - print("response:\(response): string:\(string)") - throw AuthorizedError.serverError("\(response): \(string)") + throw AuthorizedError.serverError( + statusCode: response.statusCode, + body: string, + ) } return } catch let error as AuthorizedError { @@ -225,6 +282,15 @@ struct Transport { case patch } + private func apply( + headers: [String: String], + to request: inout URLRequest, + ) { + for (field, value) in headers { + request.setValue(value, forHTTPHeaderField: field) + } + } + } private extension Data { diff --git a/Friendly/Sources/UserDetails.swift b/Friendly/Sources/UserDetails.swift index 6948e28..4c3756f 100644 --- a/Friendly/Sources/UserDetails.swift +++ b/Friendly/Sources/UserDetails.swift @@ -6,6 +6,7 @@ struct UserDetails { let interests: [Interest] let avatar: FileDescriptor? let socialLink: SocialLink? + let email: String? func serializable() -> UserDetailsSerializable { return UserDetailsSerializable( @@ -16,6 +17,7 @@ struct UserDetails { interests: interests.map(\.string), avatar: avatar?.serializable(), socialLink: socialLink?.string, + email: email, ) } } diff --git a/Friendly/Sources/UserDetailsSerializable.swift b/Friendly/Sources/UserDetailsSerializable.swift index c1db234..fb83301 100644 --- a/Friendly/Sources/UserDetailsSerializable.swift +++ b/Friendly/Sources/UserDetailsSerializable.swift @@ -6,6 +6,7 @@ struct UserDetailsSerializable: Codable { let interests: [String] let avatar: FileDescriptorSerializable? let socialLink: String? + let email: String? func typed() throws -> UserDetails { let socialLink: SocialLink? = @@ -24,6 +25,7 @@ struct UserDetailsSerializable: Codable { }, avatar: try avatar?.typed(), socialLink: socialLink, + email: email, ) } } diff --git a/Project.swift b/Project.swift index 9e09f7a..6a451d0 100644 --- a/Project.swift +++ b/Project.swift @@ -39,7 +39,8 @@ let project = Project( base: [ "OTHER_LDFLAGS": "-ObjC", "MARKETING_VERSION": "1.0", - "CURRENT_PROJECT_VERSION": "1" + "CURRENT_PROJECT_VERSION": "1", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", ], ) ),