mobile: onboarding (#618)

* mobile: onboarding

* ios onboarding: create profile and make connection

* how SimpleX works

* connect via link

* remove separate view for connecting via link, fix bugs

* remove unused files

* fix help on small screens, update how it works page

* layout

* add About to settings, tidy up

* rename function

* update layout

* translations

* translation corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* correction

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* corrections

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>

* fix translations/layout

Co-authored-by: JRoberts <8711996+jr-simplex@users.noreply.github.com>
This commit is contained in:
Evgeny Poberezkin
2022-05-09 09:52:09 +01:00
committed by GitHub
parent 3d2315a117
commit dcaefd6566
23 changed files with 1170 additions and 407 deletions

View File

@@ -13,27 +13,22 @@ struct ContentView: View {
@State private var showNotificationAlert = false
var body: some View {
if let user = chatModel.currentUser {
ChatListView(user: user)
.onAppear {
do {
try apiStartChat()
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
chatModel.userAddress = try apiGetUserAddress()
chatModel.userSMPServers = try getUserSMPServers()
chatModel.chats = try apiGetChats()
} catch {
fatalError("Failed to start or load chats: \(error)")
ZStack {
if let step = chatModel.onboardingStage {
if case .onboardingComplete = step,
let user = chatModel.currentUser {
ChatListView(user: user)
.onAppear {
NtfManager.shared.requestAuthorization(onDeny: {
alertManager.showAlert(notificationAlert())
})
}
ChatReceiver.shared.start()
NtfManager.shared.requestAuthorization(onDeny: {
alertManager.showAlert(notificationAlert())
})
} else {
OnboardingView(onboarding: step)
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
} else {
WelcomeView()
}
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
}
func notificationAlert() -> Alert {
@@ -50,6 +45,37 @@ struct ContentView: View {
}
}
func connectViaUrl() {
let m = ChatModel.shared
if let url = m.appOpenUrl {
m.appOpenUrl = nil
AlertManager.shared.showAlert(connectViaUrlAlert(url))
}
}
func connectViaUrlAlert(_ url: URL) -> Alert {
var path = url.path
logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
if (path == "/contact" || path == "/invitation") {
path.removeFirst()
let action: ConnReqType = path == "contact" ? .contact : .invitation
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
let title: LocalizedStringKey
if case .contact = action { title = "Connect via contact link?" }
else { title = "Connect via one-time link?" }
return Alert(
title: Text(title),
message: Text("Your profile will be sent to the contact that you received this link from"),
primaryButton: .default(Text("Connect")) {
connectViaLink(link)
},
secondaryButton: .cancel()
)
} else {
return Alert(title: Text("Error: URL is invalid"))
}
}
final class AlertManager: ObservableObject {
static let shared = AlertManager()
@Published var presentAlert = false

View File

@@ -11,6 +11,7 @@ import Combine
import SwiftUI
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var currentUser: User?
// list of chat "previews"
@Published var chats: [Chat] = []

View File

@@ -10,6 +10,7 @@ import Foundation
import UIKit
import Dispatch
import BackgroundTasks
import SwiftUI
private var chatController: chat_ctrl?
@@ -261,6 +262,16 @@ func apiConnect(connReq: String) async throws -> ConnReqType? {
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
)
return nil
case let .chatCmdError(.errorAgent(.INTERNAL(internalErr))):
if internalErr == "SEUniqueID" {
am.showAlertMsg(
title: "Already connected?",
message: "It seems like you are already connected via this link. If it is not the case, there was an error (\(responseError(r)))."
)
return nil
} else {
throw r
}
default: throw r
}
}
@@ -424,13 +435,40 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
}
func initializeChat() {
logger.debug("initializeChat")
do {
ChatModel.shared.currentUser = try apiGetActiveUser()
let m = ChatModel.shared
m.currentUser = try apiGetActiveUser()
if m.currentUser == nil {
m.onboardingStage = .step1_SimpleXInfo
} else {
startChat()
}
} catch {
fatalError("Failed to initialize chat controller or database: \(error)")
}
}
func startChat() {
logger.debug("startChat")
do {
let m = ChatModel.shared
try apiStartChat()
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
m.userAddress = try apiGetUserAddress()
m.userSMPServers = try getUserSMPServers()
m.chats = try apiGetChats()
withAnimation {
m.onboardingStage = m.chats.isEmpty
? .step3_MakeConnection
: .onboardingComplete
}
ChatReceiver.shared.start()
} catch {
fatalError("Failed to start or load chats: \(error)")
}
}
class ChatReceiver {
private var receiveLoop: Task<Void, Never>?
private var receiveMessages = true

View File

@@ -28,30 +28,19 @@ struct ChatHelp: View {
}
VStack(alignment: .leading, spacing: 10) {
Text("To start a new chat")
Text("To make a new connection")
.font(.title2)
.fontWeight(.bold)
HStack(spacing: 8) {
Text("Tap button ")
NewChatButton()
Text("above, then:")
Text("above, then choose:")
}
Text("**Add new contact**: to create your one-time QR Code for your contact.")
Text("**Scan QR code**: to connect to your contact who shows QR code to you.")
}
.padding(.top, 24)
VStack(alignment: .leading, spacing: 10) {
Text("To connect via link")
.font(.title2)
.fontWeight(.bold)
Text("If you received SimpleX Chat invitation link you can open it in your browser:")
Text("💻 desktop: scan displayed QR code from the app, via **Scan QR code**.")
Text("📱 mobile: tap **Open in mobile app**, then tap **Connect** in the app.")
Text("**Create link / QR code** for your contact to use.")
Text("**Paste received link** or open it in the browser and tap **Open in mobile app**.")
Text("**Scan QR code**: to connect to your contact in person or via video call.")
}
.padding(.top, 24)
}

View File

@@ -21,13 +21,9 @@ struct ChatListView: View {
var body: some View {
let v = NavigationView {
List {
if chatModel.chats.isEmpty {
ChatHelp(showSettings: $showSettings)
} else {
ForEach(filteredChats()) { chat in
ChatListNavLink(chat: chat, showCallView: $showCallView)
.padding(.trailing, -16)
}
ForEach(filteredChats()) { chat in
ChatListNavLink(chat: chat, showCallView: $showCallView)
.padding(.trailing, -16)
}
}
.onChange(of: chatModel.chatId) { _ in
@@ -36,15 +32,15 @@ struct ChatListView: View {
chatModel.popChat(chatId)
}
}
.onChange(of: chatModel.appOpenUrl) { _ in
if let url = chatModel.appOpenUrl {
chatModel.appOpenUrl = nil
AlertManager.shared.showAlert(connectViaUrlAlert(url))
}
.onChange(of: chatModel.chats.isEmpty) { empty in
if !empty { return }
withAnimation { chatModel.onboardingStage = .step3_MakeConnection }
}
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
.onAppear() { connectViaUrl() }
.offset(x: -8)
.listStyle(.plain)
.navigationTitle(chatModel.chats.isEmpty ? "Welcome \(user.displayName)!" : "Your chats")
.navigationTitle("Your chats")
.navigationBarTitleDisplayMode(chatModel.chats.count > 8 ? .inline : .large)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
@@ -101,29 +97,6 @@ struct ChatListView: View {
}
}
private func connectViaUrlAlert(_ url: URL) -> Alert {
var path = url.path
logger.debug("ChatListView.connectViaUrlAlert path: \(path)")
if (path == "/contact" || path == "/invitation") {
path.removeFirst()
let action: ConnReqType = path == "contact" ? .contact : .invitation
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
let title: LocalizedStringKey
if case .contact = action { title = "Connect via contact link?" }
else { title = "Connect via one-time link?" }
return Alert(
title: Text(title),
message: Text("Your profile will be sent to the contact that you received this link from"),
primaryButton: .default(Text("Connect")) {
connectViaLink(link)
},
secondaryButton: .cancel()
)
} else {
return Alert(title: Text("Error: URL is invalid"))
}
}
private func answerCallAlert(_ contact: Contact, _ invitation: CallInvitation) {
AlertManager.shared.showAlert(Alert(
title: Text("Incoming call"),

View File

@@ -12,26 +12,25 @@ import CoreImage.CIFilterBuiltins
struct AddContactView: View {
var connReqInvitation: String
var body: some View {
VStack {
Text("Add contact")
VStack(alignment: .leading) {
Text("One-time invitation link")
.font(.title)
.padding(.vertical)
Text("Your contact can scan it from the app")
.padding(.bottom)
Text("Show QR code to your contact\nto scan from the app")
.font(.title2)
.multilineTextAlignment(.center)
QRCode(uri: connReqInvitation)
.padding()
Text("If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel.")
.font(.subheadline)
.multilineTextAlignment(.center)
.padding(.horizontal)
.padding(.bottom)
Text("If you can't meet in person, **show QR code in the video call**, or share the link.")
.padding(.bottom)
Button {
showShareSheet(items: [connReqInvitation])
} label: {
Label("Share invitation link", systemImage: "square.and.arrow.up")
}
.padding()
.frame(maxWidth: .infinity, alignment: .center)
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
}

View File

@@ -8,12 +8,18 @@
import SwiftUI
enum NewChatAction: Identifiable {
case createLink
case pasteLink
case scanQRCode
var id: NewChatAction { get { self } }
}
struct NewChatButton: View {
@State private var showAddChat = false
@State private var addContact = false
@State private var connReqInvitation: String = ""
@State private var scanToConnect = false
@State private var pasteToConnect = false
@State private var connReq: String = ""
@State private var actionSheet: NewChatAction?
var body: some View {
Button { showAddChat = true } label: {
@@ -21,24 +27,22 @@ struct NewChatButton: View {
}
.confirmationDialog("Add contact to start a new chat", isPresented: $showAddChat, titleVisibility: .visible) {
Button("Create link / QR code") { addContactAction() }
Button("Paste received link") { pasteToConnect = true }
Button("Scan QR code") { scanToConnect = true }
Button("Paste received link") { actionSheet = .pasteLink }
Button("Scan QR code") { actionSheet = .scanQRCode }
}
.sheet(item: $actionSheet) { sheet in
switch sheet {
case .createLink: AddContactView(connReqInvitation: connReq)
case .pasteLink: PasteToConnectView(openedSheet: $actionSheet)
case .scanQRCode: ScanToConnectView(openedSheet: $actionSheet)
}
}
.sheet(isPresented: $addContact, content: {
AddContactView(connReqInvitation: connReqInvitation)
})
.sheet(isPresented: $scanToConnect, content: {
ScanToConnectView(openedSheet: $scanToConnect)
})
.sheet(isPresented: $pasteToConnect, content: {
PasteToConnectView(openedSheet: $pasteToConnect)
})
}
func addContactAction() {
do {
connReqInvitation = try apiAddContact()
addContact = true
connReq = try apiAddContact()
actionSheet = .createLink
} catch {
DispatchQueue.global().async {
connectionErrorAlert(error)
@@ -53,12 +57,12 @@ enum ConnReqType: Equatable {
case invitation
}
func connectViaLink(_ connectionLink: String, _ openedSheet: Binding<Bool>? = nil) {
func connectViaLink(_ connectionLink: String, _ openedSheet: Binding<NewChatAction?>? = nil) {
Task {
do {
let res = try await apiConnect(connReq: connectionLink)
DispatchQueue.main.async {
openedSheet?.wrappedValue = false
openedSheet?.wrappedValue = nil
if let connReqType = res {
connectionReqSentAlert(connReqType)
}
@@ -66,7 +70,7 @@ func connectViaLink(_ connectionLink: String, _ openedSheet: Binding<Bool>? = ni
} catch {
logger.error("connectViaLink apiConnect error: \(responseError(error))")
DispatchQueue.main.async {
openedSheet?.wrappedValue = false
openedSheet?.wrappedValue = nil
connectionErrorAlert(error)
}
}
@@ -74,7 +78,7 @@ func connectViaLink(_ connectionLink: String, _ openedSheet: Binding<Bool>? = ni
}
func connectionErrorAlert(_ error: Error) {
AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(error.localizedDescription)")
AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(responseError(error))")
}
func connectionReqSentAlert(_ type: ConnReqType) {

View File

@@ -9,23 +9,19 @@
import SwiftUI
struct PasteToConnectView: View {
@Binding var openedSheet: Bool
@Binding var openedSheet: NewChatAction?
@State private var connectionLink: String = ""
var body: some View {
VStack(alignment: .leading) {
Text("Connect via link")
.font(.title)
.padding([.bottom])
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical)
Text("Paste the link you received into the box below to connect with your contact.")
.multilineTextAlignment(.leading)
Text("Your profile will be sent to the contact that you received this link from")
.multilineTextAlignment(.leading)
.padding(.bottom)
TextEditor(text: $connectionLink)
.onSubmit(connect)
.font(.body)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.allowsTightening(false)
@@ -60,9 +56,9 @@ struct PasteToConnectView: View {
.padding(.bottom)
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button")
.multilineTextAlignment(.leading)
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
private func connect() {
@@ -72,7 +68,7 @@ struct PasteToConnectView: View {
struct PasteToConnectView_Previews: PreviewProvider {
static var previews: some View {
@State var openedSheet: Bool = true
@State var openedSheet: NewChatAction? = nil
return PasteToConnectView(openedSheet: $openedSheet)
}
}

View File

@@ -10,28 +10,26 @@ import SwiftUI
import CodeScanner
struct ScanToConnectView: View {
@Binding var openedSheet: Bool
@Binding var openedSheet: NewChatAction?
var body: some View {
VStack {
VStack(alignment: .leading) {
Text("Scan QR code")
.font(.title)
.padding(.bottom)
.padding(.vertical)
Text("Your chat profile will be sent to your contact")
.font(.title2)
.multilineTextAlignment(.center)
.padding()
.padding(.bottom)
ZStack {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.border(.gray)
}
.padding(12)
.padding(.bottom)
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
.font(.subheadline)
.multilineTextAlignment(.center)
.padding(.horizontal)
.padding(.bottom)
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
@@ -40,14 +38,14 @@ struct ScanToConnectView: View {
Task { connectViaLink(r.string, $openedSheet) }
case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
openedSheet = false
openedSheet = nil
}
}
}
struct ConnectContactView_Previews: PreviewProvider {
static var previews: some View {
@State var openedSheet: Bool = true
@State var openedSheet: NewChatAction? = nil
return ScanToConnectView(openedSheet: $openedSheet)
}
}

View File

@@ -0,0 +1,124 @@
//
// CreateProfile.swift
// SimpleX (iOS)
//
// Created by Evgeny on 07/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct CreateProfile: View {
@EnvironmentObject var m: ChatModel
@State private var displayName: String = ""
@State private var fullName: String = ""
@FocusState private var focusDisplayName
@FocusState private var focusFullName
var body: some View {
GeometryReader { g in
VStack(alignment: .leading) {
Text("Create your profile")
.font(.largeTitle)
.padding(.bottom, 4)
Text("Your profile, contacts and delivered messages are stored on your device.")
.padding(.bottom, 4)
Text("The profile is only shared with your contacts.")
.padding(.bottom)
ZStack(alignment: .topLeading) {
if !validDisplayName(displayName) {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
.padding(.top, 4)
}
textField("Display name", text: $displayName)
.focused($focusDisplayName)
.submitLabel(.next)
.onSubmit {
if canCreateProfile() { focusFullName = true }
else { focusDisplayName = true }
}
}
textField("Full name (optional)", text: $fullName)
.focused($focusFullName)
.submitLabel(.go)
.onSubmit {
if canCreateProfile() { createProfile() }
else { focusFullName = true }
}
Spacer()
HStack {
Button {
hideKeyboard()
withAnimation { m.onboardingStage = .step1_SimpleXInfo }
} label: {
HStack {
Image(systemName: "lessthan")
Text("About SimpleX")
}
}
Spacer()
HStack {
Button {
createProfile()
} label: {
Text("Create")
Image(systemName: "greaterthan")
}
.disabled(!canCreateProfile())
}
}
}
.onAppear() {
focusDisplayName = true
}
}
.padding()
}
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
TextField(placeholder, text: text)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.leading, 28)
.padding(.bottom)
}
func createProfile() {
hideKeyboard()
let profile = Profile(
displayName: displayName,
fullName: fullName
)
do {
m.currentUser = try apiCreateActiveUser(profile)
startChat()
withAnimation { m.onboardingStage = .step3_MakeConnection }
} catch {
fatalError("Failed to create user: \(error)")
}
}
func hideKeyboard() {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
func validDisplayName(_ name: String) -> Bool {
name.firstIndex(of: " ") == nil
}
func canCreateProfile() -> Bool {
displayName != "" && validDisplayName(displayName)
}
}
struct CreateProfile_Previews: PreviewProvider {
static var previews: some View {
CreateProfile()
}
}

View File

@@ -0,0 +1,54 @@
//
// HowItWorks.swift
// SimpleX (iOS)
//
// Created by Evgeny on 08/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct HowItWorks: View {
@EnvironmentObject var m: ChatModel
var onboarding: Bool
var body: some View {
VStack(alignment: .leading) {
Text("How SimpleX works")
.font(.largeTitle)
.padding(.vertical)
ScrollView {
VStack(alignment: .leading) {
Group {
Text("Many people asked: *if SimpleX has no user identifiers, how can it deliver messages?*")
Text("To protect privacy, instead of user IDs used by all other platforms, SimpleX has identifiers for message queues, separate for each of your contacts.")
Text("You control through which server(s) **to receive** the messages, your contacts the servers you use to message them.")
Text("Only client devices store user profiles, contacts, groups, and messages sent with **2-layer end-to-end encryption**.")
if onboarding {
Text("Read more in our GitHub repository.")
} else {
Text("Read more in our [GitHub repository](https://github.com/simplex-chat/simplex-chat#readme).")
}
}
.padding(.bottom)
}
}
Spacer()
if onboarding {
OnboardingActionButton()
.padding(.bottom, 8)
}
}
.lineLimit(10)
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
}
struct HowItWorks_Previews: PreviewProvider {
static var previews: some View {
HowItWorks(onboarding: true)
}
}

View File

@@ -0,0 +1,146 @@
//
// MakeConnection.swift
// SimpleX (iOS)
//
// Created by Evgeny on 07/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct MakeConnection: View {
@EnvironmentObject var m: ChatModel
@State private var connReq: String = ""
@State private var actionSheet: NewChatAction?
var body: some View {
VStack(alignment: .leading) {
SettingsButton().padding(.bottom, 1)
if let user = m.currentUser {
Text("Welcome \(user.displayName)!")
.font(.largeTitle)
.multilineTextAlignment(.leading)
.padding(.bottom, 8)
} else {
Text("Make a private connection")
.font(.largeTitle)
.padding(.bottom)
}
ScrollView {
VStack(alignment: .leading) {
Text("To make your first private connection, choose **one of the following**:")
.padding(.bottom)
actionRow(
icon: "qrcode",
title: "Create 1-time link / QR code",
text: "It's secure to share - only one contact can use it."
) { addContactAction() }
actionRow(
icon: "link",
title: "Paste the link you received",
text: "Or open the link in the browser and tap **Open in mobile**."
) { actionSheet = .pasteLink }
actionRow(
icon: "qrcode.viewfinder",
title: "Scan contact's QR code",
text: "In person or via a video call the most secure way to connect."
) { actionSheet = .scanQRCode }
Text("or")
.padding(.bottom)
.frame(maxWidth: .infinity)
actionRow(
icon: "number",
title: "Connect with the developers",
text: "To ask any questions and to receive SimpleX Chat updates."
) {
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
}
}
}
}
Spacer()
Button {
withAnimation { m.onboardingStage = .step1_SimpleXInfo }
} label: {
HStack {
Image(systemName: "lessthan")
Text("About SimpleX")
}
}
.padding(.bottom, 8)
.padding(.bottom)
}
.sheet(item: $actionSheet) { sheet in
switch sheet {
case .createLink: AddContactView(connReqInvitation: connReq)
case .pasteLink: PasteToConnectView(openedSheet: $actionSheet)
case .scanQRCode: ScanToConnectView(openedSheet: $actionSheet)
}
}
.onChange(of: actionSheet) { _ in checkOnboarding() }
.onChange(of: m.chats.isEmpty) { _ in checkOnboarding() }
.onChange(of: m.appOpenUrl) { _ in connectViaUrl() }
.onAppear() { connectViaUrl() }
.padding(.horizontal)
.frame(maxHeight: .infinity, alignment: .top)
}
private func checkOnboarding() {
if actionSheet == nil && !m.chats.isEmpty {
withAnimation { m.onboardingStage = .onboardingComplete }
}
}
private func addContactAction() {
do {
connReq = try apiAddContact()
actionSheet = .createLink
} catch {
DispatchQueue.global().async {
connectionErrorAlert(error)
}
logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)")
}
}
private func actionRow(icon: String, title: LocalizedStringKey, text: LocalizedStringKey, action: @escaping () -> Void) -> some View {
HStack(alignment: .top, spacing: 20) {
Button(action: action, label: {
Image(systemName: icon)
.resizable()
.scaledToFit()
.frame(width: 30, height: 30)
.padding(.leading, 6)
.padding(.top, 6)
})
VStack(alignment: .leading) {
Button(action: action, label: {
Text(title)
.font(.headline)
.multilineTextAlignment(.leading)
})
Text(text)
}
}
.padding(.bottom)
}
}
struct MakeConnection_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.currentUser = User.sampleData
return MakeConnection()
.environmentObject(chatModel)
}
}

View File

@@ -0,0 +1,35 @@
//
// OnboardingStepsView.swift
// SimpleX (iOS)
//
// Created by Evgeny on 07/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct OnboardingView: View {
var onboarding: OnboardingStage
var body: some View {
switch onboarding {
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
case .step2_CreateProfile: CreateProfile()
case .step3_MakeConnection: MakeConnection()
case .onboardingComplete: EmptyView()
}
}
}
enum OnboardingStage {
case step1_SimpleXInfo
case step2_CreateProfile
case step3_MakeConnection
case onboardingComplete
}
struct OnboardingStepsView_Previews: PreviewProvider {
static var previews: some View {
OnboardingView(onboarding: .step1_SimpleXInfo)
}
}

View File

@@ -0,0 +1,102 @@
//
// SimpleXInfo.swift
// SimpleX (iOS)
//
// Created by Evgeny on 07/05/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct SimpleXInfo: View {
@EnvironmentObject var m: ChatModel
@State private var showHowItWorks = false
var onboarding: Bool
var body: some View {
GeometryReader { g in
VStack(alignment: .leading) {
Image("logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: g.size.width * 0.7)
.padding(.bottom)
Text("The next generation of private messaging")
.font(.title)
.padding(.bottom)
infoRow("🎭", "Privacy redefined",
"The 1st platform without any user identifiers private by design.")
infoRow("📭", "Immune to spam and abuse",
"People can connect to you only via the links you share.")
infoRow("🤝", "Decentralized",
"Open-source protocol and code anybody can run the servers.")
Spacer()
if onboarding {
OnboardingActionButton()
Spacer()
}
Button {
showHowItWorks = true
} label: {
Label("How it works", systemImage: "info.circle")
.font(.subheadline)
}
.padding(.bottom, 8)
.frame(maxWidth: .infinity)
}
.sheet(isPresented: $showHowItWorks) {
HowItWorks(onboarding: onboarding)
}
}
.padding()
}
private func infoRow(_ emoji: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey) -> some View {
HStack(alignment: .top) {
Text(emoji)
.font(mediumEmojiFont)
.frame(width: 40)
VStack(alignment: .leading) {
Text(title).font(.headline)
Text(text)
}
}
.padding(.bottom)
}
}
struct OnboardingActionButton: View {
@EnvironmentObject var m: ChatModel
var body: some View {
if m.currentUser == nil {
actionButton("Create your profile", onboarding: .step2_CreateProfile)
} else {
actionButton("Make a private connection", onboarding: .step3_MakeConnection)
}
}
private func actionButton(_ label: LocalizedStringKey, onboarding: OnboardingStage) -> some View {
Button {
withAnimation {
m.onboardingStage = onboarding
}
} label: {
HStack {
Text(label).font(.title2)
Image(systemName: "greaterthan")
}
}
.frame(maxWidth: .infinity)
}
}
struct SimpleXInfo_Previews: PreviewProvider {
static var previews: some View {
SimpleXInfo(onboarding: true)
}
}

View File

@@ -17,6 +17,8 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as?
let DEFAULT_USE_NOTIFICATIONS = "useNotifications"
let DEFAULT_PENDING_CONNECTIONS = "pendingConnections"
private var indent: CGFloat = 36
struct SettingsView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var chatModel: ChatModel
@@ -54,29 +56,19 @@ struct SettingsView: View {
UserAddress()
.navigationTitle("Your chat address")
} label: {
HStack {
Image(systemName: "qrcode")
.padding(.trailing, 8)
Text("Your SimpleX contact address")
}
settingsRow("qrcode") { Text("Your SimpleX contact address") }
}
}
Section("Settings") {
HStack {
Image(systemName: "link")
.padding(.trailing, 8)
settingsRow("link") {
Toggle("Show pending connections", isOn: $pendingConnections)
}
NavigationLink {
SMPServers()
.navigationTitle("Your SMP servers")
} label: {
HStack {
Image(systemName: "server.rack")
.padding(.trailing, 4)
Text("SMP servers")
}
settingsRow("server.rack") { Text("SMP servers") }
}
}
@@ -86,26 +78,23 @@ struct SettingsView: View {
.navigationTitle("Welcome \(user.displayName)!")
.frame(maxHeight: .infinity, alignment: .top)
} label: {
HStack {
Image(systemName: "questionmark.circle")
.padding(.trailing, 8)
Text("How to use SimpleX Chat")
}
settingsRow("questionmark") { Text("How to use it") }
}
NavigationLink {
SimpleXInfo(onboarding: false)
.navigationBarTitle("", displayMode: .inline)
.frame(maxHeight: .infinity, alignment: .top)
} label: {
settingsRow("info") { Text("About SimpleX Chat") }
}
NavigationLink {
MarkdownHelp()
.navigationTitle("How to use markdown")
.frame(maxHeight: .infinity, alignment: .top)
} label: {
HStack {
Image(systemName: "textformat")
.padding(.trailing, 4)
Text("Markdown in messages")
}
settingsRow("textformat") { Text("Markdown in messages") }
}
HStack {
Image(systemName: "number")
.padding(.trailing, 8)
settingsRow("number") {
Button {
showSettings = false
DispatchQueue.main.async {
@@ -115,30 +104,21 @@ struct SettingsView: View {
Text("Chat with the developers")
}
}
HStack {
Image(systemName: "envelope")
.padding(.trailing, 4)
Text("[Send us email](mailto:chat@simplex.chat)")
}
settingsRow("envelope") { Text("[Send us email](mailto:chat@simplex.chat)") }
}
Section("Develop") {
NavigationLink {
TerminalView()
} label: {
HStack {
Image(systemName: "terminal")
.frame(maxWidth: 24)
.padding(.trailing, 8)
Text("Chat console")
}
settingsRow("terminal") { Text("Chat console") }
}
HStack {
ZStack(alignment: .leading) {
Image(colorScheme == .dark ? "github_light" : "github")
.resizable()
.frame(width: 24, height: 24)
.padding(.trailing, 8)
Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)")
.padding(.leading, indent)
}
// if let token = chatModel.deviceToken {
// HStack {
@@ -158,6 +138,13 @@ struct SettingsView: View {
}
}
private func settingsRow<Content : View>(_ icon: String, content: @escaping () -> Content) -> some View {
ZStack(alignment: .leading) {
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center)
content().padding(.leading, indent)
}
}
enum NotificationAlert {
case enable
case error(LocalizedStringKey, String)

View File

@@ -1,80 +0,0 @@
//
// WelcomeView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 18/01/2022.
//
import SwiftUI
struct WelcomeView: View {
@EnvironmentObject var chatModel: ChatModel
@State var displayName: String = ""
@State var fullName: String = ""
var body: some View {
GeometryReader { g in
VStack(alignment: .leading) {
Image("logo")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: g.size.width * 0.7)
.padding(.vertical)
Text("You control your chat!")
.font(.title)
.padding(.bottom)
Text("The messaging and application platform 100% private by design!")
.padding(.bottom, 8)
Text("Your profile, contacts and messages (once delivered) are only stored locally on your device.")
.padding(.bottom, 8)
Text("Create profile")
.font(.largeTitle)
.padding(.bottom, 4)
Text("(shared only with your contacts)")
.padding(.bottom)
ZStack(alignment: .topLeading) {
if !validDisplayName(displayName) {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
.padding(.top, 4)
}
TextField("Display name", text: $displayName)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.leading, 28)
.padding(.bottom, 2)
}
.padding(.bottom)
TextField("Full name (optional)", text: $fullName)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.leading, 28)
.padding(.bottom)
Button("Create") {
let profile = Profile(
displayName: displayName,
fullName: fullName
)
do {
let user = try apiCreateActiveUser(profile)
chatModel.currentUser = user
} catch {
fatalError("Failed to create user: \(error)")
}
}
.disabled(!validDisplayName(displayName) || displayName == "")
}
}
.padding()
}
func validDisplayName(_ name: String) -> Bool {
name.firstIndex(of: " ") == nil
}
}
struct WelcomeView_Previews: PreviewProvider {
static var previews: some View {
WelcomeView()
}
}