mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-08-28 12:04:11 +00:00
ios: onboarding redesign (#5252)
* ios: onboarding redesign * shorter texts * updates * more updates * remove extra padding when focused * strings --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
co-authored by
Evgeny Poberezkin
parent
1f04984a34
commit
345e0acdec
@@ -73,14 +73,20 @@ struct ChooseServerOperators: View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
if !onboarding {
|
||||
Text("Choose operators")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
let title = Text("Server operators")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
if onboarding {
|
||||
title.padding(.top, 50)
|
||||
} else {
|
||||
title
|
||||
}
|
||||
|
||||
infoText()
|
||||
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Spacer()
|
||||
|
||||
ForEach(serverOperators) { srvOperator in
|
||||
@@ -117,11 +123,10 @@ struct ChooseServerOperators: View {
|
||||
.foregroundColor(.clear)
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
.padding(.top)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(minHeight: 40)
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
|
||||
if !onboarding && !reviewForOperators.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
@@ -162,21 +167,15 @@ struct ChooseServerOperators: View {
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding()
|
||||
.padding(onboarding ? 25 : 16)
|
||||
}
|
||||
|
||||
private func infoText() -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "info.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 20, height: 20)
|
||||
.foregroundColor(theme.colors.primary)
|
||||
.onTapGesture {
|
||||
sheetItem = .showInfo
|
||||
}
|
||||
|
||||
Text("Select network operators to use.")
|
||||
Button {
|
||||
sheetItem = .showInfo
|
||||
} label: {
|
||||
Label("How it helps privacy", systemImage: "info.circle")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,8 +304,6 @@ struct ChooseServerOperators: View {
|
||||
|
||||
private func notificationsModeDestinationView() -> some View {
|
||||
SetNotificationsMode()
|
||||
.navigationTitle("Push notifications")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
@@ -334,7 +331,7 @@ struct ChooseServerOperators: View {
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, 25)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@@ -411,7 +408,7 @@ struct ChooseServerOperators: View {
|
||||
struct ChooseServerOperatorsInfoView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Network operators")
|
||||
Text("Server operators")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
|
||||
@@ -119,49 +119,67 @@ struct CreateFirstProfile: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("Create your profile")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Your profile, contacts and delivered messages are stored on your device.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("The profile is only shared with your contacts.")
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity) // Ensures it takes up the full width
|
||||
.padding(.top, 25)
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
HStack {
|
||||
let name = displayName.trimmingCharacters(in: .whitespaces)
|
||||
let validName = mkValidName(name)
|
||||
ZStack {
|
||||
ZStack(alignment: .trailing) {
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(uiColor: .tertiarySystemFill))
|
||||
)
|
||||
if name != validName {
|
||||
Button {
|
||||
showAlert(.invalidNameError(validName: validName))
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.red)
|
||||
.padding(.horizontal, 10)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
|
||||
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
TextField("Enter your name…", text: $displayName)
|
||||
.focused($focusDisplayName)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color(uiColor: .tertiarySystemFill))
|
||||
)
|
||||
}
|
||||
.padding(.top)
|
||||
|
||||
Spacer()
|
||||
|
||||
createProfileButton()
|
||||
.padding(.bottom)
|
||||
VStack(spacing: 10) {
|
||||
createProfileButton()
|
||||
if !focusDisplayName {
|
||||
onboardingButtonPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear() {
|
||||
focusDisplayName = true
|
||||
setLastVersionDefault()
|
||||
}
|
||||
.padding()
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 25)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@@ -191,8 +209,6 @@ struct CreateFirstProfile: View {
|
||||
|
||||
private func nextStepDestinationView() -> some View {
|
||||
ChooseServerOperators(onboarding: true)
|
||||
.navigationTitle("Choose operators")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
|
||||
@@ -40,8 +40,10 @@ struct HowItWorks: View {
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
createFirstProfileButton()
|
||||
.padding(.bottom)
|
||||
VStack(spacing: 10) {
|
||||
createFirstProfileButton()
|
||||
onboardingButtonPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
.lineLimit(10)
|
||||
|
||||
@@ -24,14 +24,10 @@ struct OnboardingView: View {
|
||||
CreateSimpleXAddress()
|
||||
case .step3_ChooseServerOperators:
|
||||
ChooseServerOperators(onboarding: true)
|
||||
.navigationTitle("Choose operators")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
case .step4_SetNotificationsMode:
|
||||
SetNotificationsMode()
|
||||
.navigationTitle("Push notifications")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.modifier(ThemedBackground())
|
||||
case .onboardingComplete: EmptyView()
|
||||
@@ -40,6 +36,10 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func onboardingButtonPlaceholder() -> some View {
|
||||
Spacer().frame(height: 40)
|
||||
}
|
||||
|
||||
enum OnboardingStage: String, Identifiable {
|
||||
case step1_SimpleXInfo
|
||||
case step2_CreateProfile // deprecated
|
||||
|
||||
@@ -13,41 +13,55 @@ struct SetNotificationsMode: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State private var notificationMode = NotificationsMode.instant
|
||||
@State private var showAlert: NotificationAlert?
|
||||
@State private var showInfo: Bool = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Send notifications:")
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
Text("Push Notifications")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.top, 50)
|
||||
|
||||
infoText()
|
||||
|
||||
Spacer()
|
||||
|
||||
ForEach(NotificationsMode.values) { mode in
|
||||
NtfModeSelector(mode: mode, selection: $notificationMode)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if let token = m.deviceToken {
|
||||
setNotificationsMode(token, notificationMode)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "No device token!")
|
||||
}
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
} label: {
|
||||
if case .off = notificationMode {
|
||||
Text("Use chat")
|
||||
} else {
|
||||
Text("Enable notifications")
|
||||
VStack(spacing: 10) {
|
||||
Button {
|
||||
if let token = m.deviceToken {
|
||||
setNotificationsMode(token, notificationMode)
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(title: "No device token!")
|
||||
}
|
||||
onboardingStageDefault.set(.onboardingComplete)
|
||||
m.onboardingStage = .onboardingComplete
|
||||
} label: {
|
||||
if case .off = notificationMode {
|
||||
Text("Use chat")
|
||||
} else {
|
||||
Text("Enable notifications")
|
||||
}
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
onboardingButtonPlaceholder()
|
||||
}
|
||||
.buttonStyle(OnboardingButtonStyle())
|
||||
.padding(.bottom)
|
||||
}
|
||||
.padding()
|
||||
.padding(25)
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.sheet(isPresented: $showInfo) {
|
||||
NotificationsInfoView()
|
||||
}
|
||||
}
|
||||
|
||||
private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {
|
||||
@@ -73,6 +87,15 @@ struct SetNotificationsMode: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func infoText() -> some View {
|
||||
Button {
|
||||
showInfo = true
|
||||
} label: {
|
||||
Label("How it affects privacy", systemImage: "info.circle")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NtfModeSelector: View {
|
||||
@@ -83,15 +106,24 @@ struct NtfModeSelector: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(mode.label)
|
||||
.font(.headline)
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: mode.icon)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: mode.icon == "bolt" ? 14 : 18, height: 18)
|
||||
.foregroundColor(selection == mode ? theme.colors.primary : theme.colors.secondary)
|
||||
Text(ntfModeDescription(mode))
|
||||
.lineLimit(10)
|
||||
.font(.subheadline)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(mode.label)
|
||||
.font(.headline)
|
||||
.foregroundColor(selection == mode ? theme.colors.primary : theme.colors.secondary)
|
||||
Text(ntfModeShortDescription(mode))
|
||||
.lineLimit(2)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tapped ? Color(uiColor: .secondarySystemFill) : theme.colors.background)
|
||||
@@ -107,6 +139,36 @@ struct NtfModeSelector: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsInfoView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Notification modes")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.padding(.vertical)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Group {
|
||||
ForEach(NotificationsMode.values) { mode in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(mode.label)
|
||||
.font(.headline)
|
||||
Text(ntfModeDescription(mode))
|
||||
.lineLimit(10)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
|
||||
struct NotificationsModeView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
SetNotificationsMode()
|
||||
|
||||
@@ -19,21 +19,26 @@ struct SimpleXInfo: View {
|
||||
var body: some View {
|
||||
GeometryReader { g in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Image(colorScheme == .light ? "logo" : "logo-light")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: g.size.width * 0.67)
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .center, spacing: 10) {
|
||||
Image(colorScheme == .light ? "logo" : "logo-light")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: g.size.width * 0.67)
|
||||
.padding(.bottom, 8)
|
||||
.frame(maxWidth: .infinity, minHeight: 48, alignment: .top)
|
||||
|
||||
Button {
|
||||
showHowItWorks = true
|
||||
} label: {
|
||||
Label("The future of messaging", systemImage: "info.circle")
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("The next generation of private messaging")
|
||||
.font(.title2)
|
||||
.padding(.bottom, 30)
|
||||
.padding(.horizontal, 40)
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
infoRow("privacy", "Privacy redefined",
|
||||
"The 1st platform without any user identifiers – private by design.", width: 48)
|
||||
infoRow("shield", "Immune to spam and abuse",
|
||||
@@ -45,25 +50,19 @@ struct SimpleXInfo: View {
|
||||
Spacer()
|
||||
|
||||
if onboarding {
|
||||
createFirstProfileButton()
|
||||
VStack(spacing: 10) {
|
||||
createFirstProfileButton()
|
||||
|
||||
Button {
|
||||
m.migrationState = .pasteOrScanLink
|
||||
} label: {
|
||||
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
|
||||
.font(.subheadline)
|
||||
Button {
|
||||
m.migrationState = .pasteOrScanLink
|
||||
} label: {
|
||||
Label("Migrate from another device", systemImage: "tray.and.arrow.down")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(minHeight: 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
Button {
|
||||
showHowItWorks = true
|
||||
} label: {
|
||||
Label("How it works", systemImage: "info.circle")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.bottom)
|
||||
}
|
||||
.frame(minHeight: g.size.height)
|
||||
}
|
||||
@@ -89,7 +88,9 @@ struct SimpleXInfo: View {
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding()
|
||||
.padding(.horizontal, 25)
|
||||
.padding(.top, 75)
|
||||
.padding(.bottom, 25)
|
||||
}
|
||||
|
||||
private func infoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
|
||||
@@ -104,7 +105,7 @@ struct SimpleXInfo: View {
|
||||
.padding(.trailing, 10)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.headline)
|
||||
Text(text).frame(minHeight: 40, alignment: .top)
|
||||
Text(text).frame(minHeight: 40, alignment: .top).font(.callout)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 20)
|
||||
@@ -121,7 +122,7 @@ struct SimpleXInfo: View {
|
||||
.buttonStyle(OnboardingButtonStyle(isDisabled: false))
|
||||
|
||||
NavigationLink(isActive: $createProfileNavLinkActive) {
|
||||
createProfileDestinationView()
|
||||
CreateFirstProfile()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
@@ -129,13 +130,6 @@ struct SimpleXInfo: View {
|
||||
.hidden()
|
||||
}
|
||||
}
|
||||
|
||||
private func createProfileDestinationView() -> some View {
|
||||
CreateFirstProfile()
|
||||
.navigationTitle("Create your profile")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground())
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleXInfo_Previews: PreviewProvider {
|
||||
|
||||
@@ -269,6 +269,7 @@ struct UsageConditionsView: View {
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.bottom)
|
||||
|
||||
|
||||
case let .accepted(operators):
|
||||
Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
|
||||
@@ -277,7 +278,7 @@ struct UsageConditionsView: View {
|
||||
.padding(.bottom)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, 25)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,14 @@ func ntfModeDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
|
||||
}
|
||||
}
|
||||
|
||||
func ntfModeShortDescription(_ mode: NotificationsMode) -> LocalizedStringKey {
|
||||
switch mode {
|
||||
case .off: return "Check messages when allowed."
|
||||
case .periodic: return "Check messages every 20 min."
|
||||
case .instant: return "E2E encrypted notifications."
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectionListView<Item: SelectableItem>: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var list: [Item]
|
||||
|
||||
@@ -2158,9 +2158,17 @@ public enum NotificationsMode: String, Decodable, SelectableItem {
|
||||
|
||||
public var label: LocalizedStringKey {
|
||||
switch self {
|
||||
case .off: "Local"
|
||||
case .periodic: "Periodically"
|
||||
case .instant: "Instantly"
|
||||
case .off: "No push server"
|
||||
case .periodic: "Periodic"
|
||||
case .instant: "Instant"
|
||||
}
|
||||
}
|
||||
|
||||
public var icon: String {
|
||||
switch self {
|
||||
case .off: return "arrow.clockwise"
|
||||
case .periodic: return "timer"
|
||||
case .instant: return "bolt"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user