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:
committed by
GitHub
parent
3d2315a117
commit
dcaefd6566
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
124
apps/ios/Shared/Views/Onboarding/CreateProfile.swift
Normal file
124
apps/ios/Shared/Views/Onboarding/CreateProfile.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
54
apps/ios/Shared/Views/Onboarding/HowItWorks.swift
Normal file
54
apps/ios/Shared/Views/Onboarding/HowItWorks.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
146
apps/ios/Shared/Views/Onboarding/MakeConnection.swift
Normal file
146
apps/ios/Shared/Views/Onboarding/MakeConnection.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
35
apps/ios/Shared/Views/Onboarding/OnboardingView.swift
Normal file
35
apps/ios/Shared/Views/Onboarding/OnboardingView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
102
apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
Normal file
102
apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user