Files
simplex-chat/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift
T
Evgeny 3d85480944 ui: new onboarding (#6888)
* ui: onboarding assets

* android: fix gradle version check, pass assets dir to builds

* desktop: pass assets dir to gradle builds

* ui: new onboarding (#6872)

* ios: improve onboarding

* ios version condition

* android strings

* merge keys

* refactor network conditions to old location

* ios scroll headline

* remove nav view

* kotlin: refactor network commitments page to use existing view

* remove unused keys

* update why page

* configure -> setup

* padding for app bar in why page

* fix why page

* padding

* copy translations from the website

* export localizations

* export again

* kotlin: fix why page

* fix

* import localizations

* custom layout

* padding for system bars

* paddings

* more paddings

* more padding 2

* update fonts

* fonts

* line height, padding

* paddings

* refactor notifications

* refactor ios

* notification icons in cards

* restore profile field

* padding

* desktop layout create profile

* fix

* more layout

* create profile layout

* mobile padding

* split mobile and desktop

* layout

* layout

* background

* refactor onboarding images

* use DARK theme by default

* page 3 and 4 layouts

* restructure desktop onboarding to two panes

* improve layout

* improve

* fonts, padding

* link mobile on full page

* fix, reduce noise

* change to animation

* fix animation

* refactor

* colors, animation

* import

* details

* fix padding

* fix icon

* fix

* button paddings

* accept button on terms page

* fix conditions button

* close modal

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
2026-04-27 11:46:08 +01:00

397 lines
14 KiB
Swift

//
// 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<Int64>
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..<ops.count {
var op = ops[i]
op.enabled = selectedOperatorIds.contains(op.operatorId)
ops[i] = op
}
let haveSMPStorage = ops.contains(where: { $0.enabled && $0.smpRoles.storage })
let haveSMPProxy = ops.contains(where: { $0.enabled && $0.smpRoles.proxy })
let haveXFTPStorage = ops.contains(where: { $0.enabled && $0.xftpRoles.storage })
let haveXFTPProxy = ops.contains(where: { $0.enabled && $0.xftpRoles.proxy })
if haveSMPStorage && haveSMPProxy && haveXFTPStorage && haveXFTPProxy {
return ops
} else if let firstEnabledIndex = ops.firstIndex(where: { $0.enabled }) {
var op = ops[firstEnabledIndex]
if !haveSMPStorage { op.smpRoles.storage = true }
if !haveSMPProxy { op.smpRoles.proxy = true }
if !haveXFTPStorage { op.xftpRoles.storage = true }
if !haveXFTPProxy { op.xftpRoles.proxy = true }
ops[firstEnabledIndex] = op
return ops
} else {
return nil
}
} else {
return nil
}
}
}
private enum ChooseServerOperatorsSheet: Identifiable {
case showInfo
var id: String {
switch self {
case .showInfo: return "showInfo"
}
}
}
struct ChooseServerOperators: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Environment(\.colorScheme) var colorScheme: ColorScheme
@EnvironmentObject var theme: AppTheme
var serverOperators: [ServerOperator]
@Binding var selectedOperatorIds: Set<Int64>
@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: [])
}