// // ChooseServerOperators.swift // SimpleX (iOS) // // Created by spaced4ndy on 31.10.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // // Spec: spec/client/navigation.md import SwiftUI import SimpleXChat let conditionsURL = URL(string: "https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md")! struct OnboardingButtonStyle: ButtonStyle { @EnvironmentObject var theme: AppTheme var isDisabled: Bool = false func makeBody(configuration: Configuration) -> some View { configuration.label .font(.system(size: 17, weight: .semibold)) .padding() .frame(maxWidth: .infinity) .background( isDisabled ? ( theme.colors.isLight ? .gray.opacity(0.17) : .gray.opacity(0.27) ) : theme.colors.primary ) .foregroundColor( isDisabled ? ( theme.colors.isLight ? .gray.opacity(0.4) : .white.opacity(0.2) ) : .white ) .cornerRadius(16) .scaleEffect(configuration.isPressed ? 0.95 : 1.0) } } struct OnboardingConditionsView: View { @EnvironmentObject var theme: AppTheme @Environment(\.colorScheme) var colorScheme: ColorScheme @State private var showConditionsSheet = false var selectedOperatorIds: Set var body: some View { GeometryReader { g in VStack(alignment: .leading, spacing: 10) { Spacer(minLength: 0) heroImage().frame(maxWidth: .infinity, minHeight: 80) Text("Network commitments") .font(.largeTitle) .bold() .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) .fixedSize(horizontal: false, vertical: true) Text("Operators commit to:\n- Be independent\n- Minimize metadata usage\n- Run verified open-source code") .font(.callout) .lineSpacing(2) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 4) .padding(.top, 10) .fixedSize(horizontal: false, vertical: true) Text("You commit to:\n- Only legal content in public groups\n- Respect other users - no spam") .font(.callout) .lineSpacing(2) .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 4) .padding(.top, 10) .fixedSize(horizontal: false, vertical: true) Button { showConditionsSheet = true } label: { Text("Privacy policy and conditions of use.") .fontWeight(.medium) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.leading, 4) .padding(.top, 10) .padding(.bottom, 15) Spacer(minLength: 0) acceptButton() .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) } .padding(.horizontal, 25) .padding(.top, 25) .padding(.bottom, 25) .frame(minHeight: g.size.height) } .frame(maxHeight: .infinity) .navigationBarHidden(true) .sheet(isPresented: $showConditionsSheet) { NavigationView { VStack { ConditionsTextView() .padding() acceptButton() .padding(.horizontal, 25) .padding(.bottom, 20) } .navigationTitle("Conditions of use") .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) } .modifier(ThemedBackground(grouped: true)) } } } @ViewBuilder private func heroImage() -> some View { #if SIMPLEX_ASSETS Image(colorScheme == .light ? "network-commitments" : "network-commitments-light") .resizable() .scaledToFit() #else ZStack { let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.5, scale: colorScheme == .light ? 1.2 : 1.5) LinearGradient( stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, startPoint: gp.start, endPoint: gp.end ) Image(systemName: "checkmark.shield") .font(.system(size: 72)) .foregroundColor(theme.colors.primary) } .aspectRatio(1.5, contentMode: .fit) .clipShape(RoundedRectangle(cornerRadius: 24)) .padding(.horizontal, 25) #endif } private func acceptButton() -> some View { Button { Task { do { let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId let r = try await acceptConditions(conditionsId: conditionsId, operatorIds: Array(selectedOperatorIds)) await MainActor.run { ChatModel.shared.conditions = r } if let enabledOps = enabledOperators(r.serverOperators) { let r2 = try await setServerOperators(operators: enabledOps) await MainActor.run { ChatModel.shared.conditions = r2 completeOnboarding() } } else { await MainActor.run { completeOnboarding() } } } catch let error { await MainActor.run { showAlert( NSLocalizedString("Error accepting conditions", comment: "alert title"), message: responseError(error) ) } } } } label: { Text("Accept") } .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) .disabled(selectedOperatorIds.isEmpty) } private func completeOnboarding() { let m = ChatModel.shared onboardingStageDefault.set(.onboardingComplete) m.onboardingStage = .onboardingComplete } private func enabledOperators(_ operators: [ServerOperator]) -> [ServerOperator]? { var ops = operators if !ops.isEmpty { for i in 0.. @State private var sheetItem: ChooseServerOperatorsSheet? = nil var body: some View { GeometryReader { g in ScrollView { VStack(alignment: .leading, spacing: 20) { Text("Server operators") .font(.largeTitle) .bold() .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 25) infoText() .frame(maxWidth: .infinity, alignment: .center) Spacer() ForEach(serverOperators) { srvOperator in operatorCheckView(srvOperator) } VStack { Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8) Text("You can configure servers via settings.") } .font(.footnote) .multilineTextAlignment(.center) .frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 16) Spacer() VStack(spacing: 8) { setOperatorsButton() onboardingButtonPlaceholder() } } .frame(minHeight: g.size.height) } .sheet(item: $sheetItem) { item in switch item { case .showInfo: ChooseServerOperatorsInfoView() } } .frame(maxHeight: .infinity, alignment: .top) } .frame(maxHeight: .infinity, alignment: .top) .padding(25) .interactiveDismissDisabled(selectedOperatorIds.isEmpty) } private func infoText() -> some View { Button { sheetItem = .showInfo } label: { Label("How it helps privacy", systemImage: "info.circle") .font(.headline) } } private func operatorCheckView(_ serverOperator: ServerOperator) -> some View { let checked = selectedOperatorIds.contains(serverOperator.operatorId) let icon = checked ? "checkmark.circle.fill" : "circle" let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme) return HStack(spacing: 10) { Image(serverOperator.largeLogo(colorScheme)) .resizable() .scaledToFit() .frame(height: 48) Spacer() Image(systemName: icon) .resizable() .scaledToFit() .frame(width: 26, height: 26) .foregroundColor(iconColor) } .background(theme.colors.background) .padding() .clipShape(RoundedRectangle(cornerRadius: 18)) .overlay( RoundedRectangle(cornerRadius: 18) .stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2) ) .padding(.horizontal, 2) .onTapGesture { if checked { selectedOperatorIds.remove(serverOperator.operatorId) } else { selectedOperatorIds.insert(serverOperator.operatorId) } } } private func setOperatorsButton() -> some View { Button { dismiss() } label: { Text("OK") } .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty)) .disabled(selectedOperatorIds.isEmpty) } } let operatorsPostLink = URL(string: "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html")! struct ChooseServerOperatorsInfoView: View { @Environment(\.colorScheme) var colorScheme: ColorScheme @EnvironmentObject var theme: AppTheme var body: some View { NavigationView { List { VStack(alignment: .leading, spacing: 12) { Text("The app protects your privacy by using different operators in each conversation.") Text("When more than one operator is enabled, none of them has metadata to learn who communicates with whom.") Text("For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server.") } .fixedSize(horizontal: false, vertical: true) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .padding(.top) Section { ForEach(ChatModel.shared.conditions.serverOperators) { op in operatorInfoNavLinkView(op) } } header: { Text("About operators") .foregroundColor(theme.colors.secondary) } } .navigationTitle("Server operators") .navigationBarTitleDisplayMode(.large) .modifier(ThemedBackground(grouped: true)) } } private func operatorInfoNavLinkView(_ op: ServerOperator) -> some View { NavigationLink() { OperatorInfoView(serverOperator: op) .navigationBarTitle("Network operator") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } label: { HStack { Image(op.logo(colorScheme)) .resizable() .scaledToFit() .frame(width: 24, height: 24) Text(op.tradeName) } } } } #Preview { OnboardingConditionsView(selectedOperatorIds: []) }