From 035a2f954c015b85489b8a9e2d080ab00b42dc41 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Tue, 21 Apr 2026 17:41:52 +0100 Subject: [PATCH] ui: new UX for making connections after / as part of onboarding (#6753) * ui: additional images, views for making connections and creating groups (#6750) * ios: setup for additional assets * ios build config * header * fix * update layout * more views with images * layout * layout * android images and view * fix path * fix desktop * fix desktop build * smaller image * layout * more layout * more kotlin views * group layout * padding * create group layout * more create group layout * layout * tweak layout * more tweak * config --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * ios: connecting as part of onboarding (#6754) * ios: implementation of "connecting" cards * ios: revision * fix flip * fixes * fix frame * replace nav stack with tab view * rename * update gradient and card label material * fix gradient * debug * remove debug code * update card labels * card label layout * landscape cards * layout * safe area * less bold * debug landscape * refactor titles, back inline with title in landscape * remove ignoreSafeArea * remove extra padding * refactor * clean * layout spec added to plan --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * android, desktop: connecting during onboarding - new cards (#6757) * android, desktop: connecting during onboarding - new cards * fix * change layout * fixes * fix * fix * layout * fix layout * animation * import * paddings * 350ms * font * fonts * layout * box * more layout * layout * simpler * hide toolbar heading in onboarding mode * simpler desktop layout * better desktop * revert desktop toolbar * bigger font, landscape * fix desktop * cap width * refactor, simplify * qr code scanner icon * use icon without assets * cleaner * fix * fix --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * android, desktop: connect banner after onboarding (#6761) * android, desktop: connect banner after onboarding * improve * smaller button * bigger icon, same string * fallback gradients * improve build * simpler connect screens during onboarding * left-align * update strings * improve state machine * text, padding * strings * primary color for tap to paste link * fix race condition * fix loading race --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * ios: banner and connect screens (#6767) * ios: banner and connect screens * fix * return nav * remove padding * refactor * refactor * refactor 2 * refactor 3 * refactor 4 * header * xcode files * improve * fix toolbar * toolbar 2 * no assets * no assets 2 * padding * android padding * simplify * layout * fix --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> * fix refreshable * text * fix toolbar color * rework address share logic * padding --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- apps/ios/.gitignore | 4 + apps/ios/Debug.xcconfig | 2 + apps/ios/README.md | 21 + apps/ios/Release.xcconfig | 1 + .../SimpleXAssets.xcassets/Contents.json | 6 + .../Shared/Views/ChatList/ChatListView.swift | 53 +- .../Shared/Views/NewChat/AddGroupView.swift | 43 +- .../Views/NewChat/NewChatMenuButton.swift | 10 +- .../Shared/Views/NewChat/NewChatView.swift | 125 ++++- .../Views/NewChat/OnboardingCards.swift | 309 +++++++++++ .../Onboarding/AddressCreationCard.swift | 110 ---- .../Views/Onboarding/ConnectBannerCard.swift | 113 ++++ .../Views/UserSettings/UserAddressView.swift | 120 ++++- apps/ios/SimpleX.xcodeproj/project.pbxproj | 48 +- .../xcshareddata/swiftpm/Package.resolved | 2 +- apps/multiplatform/.gitignore | 5 +- apps/multiplatform/build.gradle.kts | 3 + apps/multiplatform/common/build.gradle.kts | 30 ++ .../kotlin/chat/simplex/common/App.kt | 5 +- .../chat/simplex/common/model/ChatModel.kt | 4 +- .../chat/simplex/common/ui/theme/Theme.kt | 1 + .../common/views/chatlist/ChatListView.kt | 235 +++++--- .../common/views/helpers/BlurModifier.kt | 4 +- .../common/views/newchat/AddGroupView.kt | 35 +- .../common/views/newchat/NewChatSheet.kt | 4 +- .../common/views/newchat/NewChatView.kt | 194 ++++--- .../common/views/newchat/OnboardingCards.kt | 422 +++++++++++++++ .../views/usersettings/UserAddressView.kt | 158 ++++-- .../commonMain/resources/MR/base/strings.xml | 24 +- .../MR/images/ic_qr_code_scanner.svg | 1 + .../default/MR/images/banner_create_link.svg | 4 + .../MR/images/banner_create_link_light.svg | 4 + .../default/MR/images/banner_paste_link.svg | 4 + .../MR/images/banner_paste_link_light.svg | 4 + .../MR/images/card_connect_via_link_alpha.svg | 4 + .../card_connect_via_link_alpha_light.svg | 4 + .../card_create_your_public_address_alpha.svg | 4 + ...create_your_public_address_alpha_light.svg | 4 + .../card_invite_someone_privately_alpha.svg | 4 + ...d_invite_someone_privately_alpha_light.svg | 4 + .../card_let_someone_connect_to_you_alpha.svg | 4 + ...let_someone_connect_to_you_alpha_light.svg | 4 + .../default/MR/images/connect_via_link.svg | 4 + .../MR/images/connect_via_link_light.svg | 4 + .../MR/images/connect_via_link_small.svg | 4 + .../images/connect_via_link_small_light.svg | 4 + .../assets/default/MR/images/create_group.svg | 4 + .../default/MR/images/create_group_light.svg | 4 + .../default/MR/images/one_time_link.svg | 4 + .../default/MR/images/one_time_link_light.svg | 4 + .../default/MR/images/one_time_link_small.svg | 4 + .../MR/images/one_time_link_small_light.svg | 4 + .../default/MR/images/simplex_address.svg | 4 + .../MR/images/simplex_address_light.svg | 4 + .../MR/images/simplex_address_small.svg | 4 + .../MR/images/simplex_address_small_light.svg | 4 + .../chat/simplex/common/StoreWindowState.kt | 3 +- apps/multiplatform/local.properties.example | 2 + plans/2026-04-06-onboarding-cards-compose.md | 492 +++++++++++++++++ plans/2026-04-06-onboarding-cards-ios.md | 501 ++++++++++++++++++ scripts/android/copy-assets.sh | 21 + scripts/ios/copy-assets.sh | 50 ++ 62 files changed, 2858 insertions(+), 407 deletions(-) create mode 100644 apps/ios/Debug.xcconfig create mode 100644 apps/ios/Release.xcconfig create mode 100644 apps/ios/Shared/SimpleXAssets.xcassets/Contents.json create mode 100644 apps/ios/Shared/Views/NewChat/OnboardingCards.swift delete mode 100644 apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift create mode 100644 apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg create mode 100644 plans/2026-04-06-onboarding-cards-compose.md create mode 100644 plans/2026-04-06-onboarding-cards-ios.md create mode 100755 scripts/android/copy-assets.sh create mode 100755 scripts/ios/copy-assets.sh diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore index 3d152a0610..ea8e911891 100644 --- a/apps/ios/.gitignore +++ b/apps/ios/.gitignore @@ -69,3 +69,7 @@ Libraries/ Shared/MyPlayground.playground/* testpush.sh + +# Local build config and generated assets +Local.xcconfig +Shared/SimpleXAssets.xcassets/*.imageset diff --git a/apps/ios/Debug.xcconfig b/apps/ios/Debug.xcconfig new file mode 100644 index 0000000000..7f0389c760 --- /dev/null +++ b/apps/ios/Debug.xcconfig @@ -0,0 +1,2 @@ +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; +#include? "Local.xcconfig" diff --git a/apps/ios/README.md b/apps/ios/README.md index fb6a6ed40d..1e987f655e 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -85,6 +85,27 @@ Workflow: - `Product > Export Localizations` - Export XLIFF files - `Product > Import Localizations` - Import updated translations +## SimpleX Assets + +The app includes optional assets behind the `SIMPLEX_ASSETS` Swift compilation flag. Without setup, the app builds normally without them. + +### Setup + +Create `Local.xcconfig` (gitignored) in the `apps/ios/` directory: +``` +SIMPLEX_ASSETS_DIR = /path/to/assets +SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) SIMPLEX_ASSETS +``` + +The copy script (`scripts/ios/copy-assets.sh`) runs as a build phase on each build but exits immediately if `SIMPLEX_ASSETS` is not set. + +### Updating assets + +When source images change, regenerate resized images (requires ImageMagick): +```bash +cd path/to/assets && ./resize.sh +``` + ## Background Capabilities Configured in Info.plist: diff --git a/apps/ios/Release.xcconfig b/apps/ios/Release.xcconfig new file mode 100644 index 0000000000..234f81e782 --- /dev/null +++ b/apps/ios/Release.xcconfig @@ -0,0 +1 @@ +#include? "Local.xcconfig" diff --git a/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json b/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/Shared/SimpleXAssets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index c881b5c583..967fedf293 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -294,36 +294,40 @@ struct ChatListView: View { @ToolbarContentBuilder var topToolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { leadingToolbarItem } - ToolbarItem(placement: .principal) { SubsStatusIndicator() } + ToolbarItem(placement: .principal) { if !shouldShowOnboarding { SubsStatusIndicator() } } ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem } } - + @ToolbarContentBuilder var bottomToolbar: some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 ToolbarItem(placement: .bottomBar) { HStack { leadingToolbarItem.padding(.bottom, padding) Spacer() - SubsStatusIndicator().padding(.bottom, padding) - Spacer() + if !shouldShowOnboarding { + SubsStatusIndicator().padding(.bottom, padding) + Spacer() + } trailingToolbarItem.padding(.bottom, padding) } .contentShape(Rectangle()) .onTapGesture { scrollToSearchBar = true } } } - + @ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent { let padding: Double = Self.hasHomeIndicator ? 0 : 14 ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) { leadingToolbarItem.padding(.bottom, padding) Spacer() - SubsStatusIndicator().padding(.bottom, padding) - Spacer() + if !shouldShowOnboarding { + SubsStatusIndicator().padding(.bottom, padding) + Spacer() + } trailingToolbarItem.padding(.bottom, padding) } } - + @ViewBuilder var leadingToolbarItem: some View { let user = chatModel.currentUser ?? User.sampleData ZStack(alignment: .topTrailing) { @@ -349,7 +353,34 @@ struct ChatListView: View { } } - private var chatList: some View { + private var shouldShowOnboarding: Bool { + !addressCreationCardShown && !chatModel.chats.isEmpty && !hasConversations + } + + private var hasConversations: Bool { + chatModel.chats.contains { chat in + switch chat.chatInfo { + case .local: return false + case let .direct(contact): return !contact.chatDeleted && !contact.isContactCard + case .group: return true + case .contactRequest: return false + case .contactConnection: return false + case .invalidJSON: return false + } + } + } + + @ViewBuilder private var chatList: some View { + if shouldShowOnboarding { + ConnectOnboardingView() + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .modifier(ThemedBackground()) + } else { + chatListContent + } + } + + private var chatListContent: some View { let cs = filteredChats() return ZStack { ScrollViewReader { scrollProxy in @@ -396,8 +427,8 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } - if !addressCreationCardShown { - AddressCreationCard() + if !addressCreationCardShown && hasConversations { + ConnectBannerCard() .padding(.vertical, 6) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .listRowSeparator(.hidden) diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 87cf377623..3ce4d1fa40 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -10,6 +10,7 @@ import SwiftUI import SimpleXChat struct AddGroupView: View { + @Environment(\.colorScheme) var colorScheme @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @Environment(\.dismiss) var dismiss: DismissAction @@ -66,29 +67,39 @@ struct AddGroupView: View { func createGroupView() -> some View { List { Group { - ZStack(alignment: .center) { - ZStack(alignment: .topTrailing) { - ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128) - if profile.image != nil { - Button { - profile.image = nil - } label: { - Image(systemName: "multiply") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 12) + HStack(spacing: 0) { + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, iconName: "person.2.circle.fill", size: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } } } - } - editImageButton { showChooseSource = true } - .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable + } + .frame(maxWidth: .infinity) + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "create-group" : "create-group-light") + .resizable() + .scaledToFit() + .frame(height: 140) + .frame(maxWidth: .infinity) + #endif } - .frame(maxWidth: .infinity, alignment: .center) + .frame(maxWidth: .infinity) } .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0)) Section { groupNameTextField() diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 8e62923f3f..177f8761f4 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -55,7 +55,7 @@ struct NewChatSheet: View { let showArchive = chatModel.chats.contains { $0.chatInfo.contact?.chatDeleted == true } let v = NavigationView { viewBody(showArchive) - .navigationTitle("New message") + .navigationTitle("New chat") .navigationBarTitleDisplayMode(.large) .navigationBarHidden(searchMode) .modifier(ThemedBackground(grouped: true)) @@ -99,9 +99,8 @@ struct NewChatSheet: View { Section { NavigationLink(isActive: $isAddContactActive) { NewChatView(selection: .invite) - .navigationTitle("New chat") .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) + .navigationBarTitleDisplayMode(.inline) } label: { navigateOnTap(Label("Create 1-time link", systemImage: "link.badge.plus")) { isAddContactActive = true @@ -109,9 +108,8 @@ struct NewChatSheet: View { } NavigationLink(isActive: $isScanPasteLinkActive) { NewChatView(selection: .connect, showQRCodeScanner: true) - .navigationTitle("New chat") .modifier(ThemedBackground(grouped: true)) - .navigationBarTitleDisplayMode(.large) + .navigationBarTitleDisplayMode(.inline) } label: { navigateOnTap(Label("Scan / Paste link", systemImage: "qrcode")) { isScanPasteLinkActive = true @@ -131,7 +129,7 @@ struct NewChatSheet: View { .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } label: { - Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right.circle.fill") + Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right") } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 7a5e8fbbc1..fab8f8a143 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -80,6 +80,7 @@ struct NewChatView: View { @EnvironmentObject var theme: AppTheme @State var selection: NewChatOption @State var showQRCodeScanner = false + var onboarding: Bool = false @State private var invitationUsed: Bool = false @State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil) @State private var showShortLink = true @@ -91,17 +92,19 @@ struct NewChatView: View { var body: some View { VStack(alignment: .leading) { - Picker("New chat", selection: $selection) { - Label("1-time link", systemImage: "link") - .tag(NewChatOption.invite) - Label("Connect via link", systemImage: "qrcode") - .tag(NewChatOption.connect) - } - .pickerStyle(.segmented) - .padding() - .onChange(of: $selection.wrappedValue) { opt in - if opt == NewChatOption.connect { - showQRCodeScanner = true + if !onboarding { + Picker("New chat", selection: $selection) { + Label("1-time link", systemImage: "link") + .tag(NewChatOption.invite) + Label("Connect via link", systemImage: "qrcode") + .tag(NewChatOption.connect) + } + .pickerStyle(.segmented) + .padding() + .onChange(of: $selection.wrappedValue) { opt in + if opt == NewChatOption.connect { + showQRCodeScanner = true + } } } @@ -116,7 +119,7 @@ struct NewChatView: View { } } if case .connect = selection { - ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert) + ConnectView(showQRCodeScanner: $showQRCodeScanner, pastedLink: $pastedLink, alert: $alert, onboarding: onboarding) .transition(.move(edge: .trailing)) } } @@ -141,16 +144,22 @@ struct NewChatView: View { } default: () } - } + }, + including: onboarding ? .subviews : .all ) } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - InfoSheetButton { - AddContactLearnMore(showTitle: true) + if !onboarding { + InfoSheetButton { + AddContactLearnMore(showTitle: true) + } + } else { + Image(systemName: "info.circle").opacity(0) } } } + .if(onboarding) { $0.navigationBarTitleDisplayMode(.inline) } .modifier(ThemedBackground(grouped: true)) .onChange(of: invitationUsed) { used in if used && !(m.showingInvitation?.connChatUsed ?? true) { @@ -179,7 +188,8 @@ struct NewChatView: View { contactConnection: $contactConnection, connLinkInvitation: $connLinkInvitation, showShortLink: $showShortLink, - choosingProfile: $choosingProfile + choosingProfile: $choosingProfile, + onboarding: onboarding ) } else if creatingConnReq { creatingLinkProgressView() @@ -239,6 +249,7 @@ private func incognitoProfileImage() -> some View { } private struct InviteView: View { + @Environment(\.colorScheme) var colorScheme @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @Binding var invitationUsed: Bool @@ -246,18 +257,19 @@ private struct InviteView: View { @Binding var connLinkInvitation: CreatedConnLink @Binding var showShortLink: Bool @Binding var choosingProfile: Bool + var onboarding: Bool = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false var body: some View { List { - Section(header: Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary)) { + Section(header: sectionHeader) { shareLinkView() } .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) qrCodeView() - if let selectedProfile = chatModel.currentUser { + if !onboarding, let selectedProfile = chatModel.currentUser { Section { NavigationLink { ActiveProfilePicker( @@ -281,9 +293,9 @@ private struct InviteView: View { } header: { Text("Share profile").foregroundColor(theme.colors.secondary) } footer: { - if incognitoDefault { - Text("A new random profile will be shared.") - } + if incognitoDefault { + Text("A new random profile will be shared.") + } } } } @@ -295,16 +307,52 @@ private struct InviteView: View { } } + private var sectionHeader: some View { + #if SIMPLEX_ASSETS + VStack(alignment: .leading, spacing: 0) { + Image(colorScheme == .light + ? (onboarding ? "one-time-link" : "one-time-link-small") + : (onboarding ? "one-time-link-light" : "one-time-link-small-light")) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + sectionHeaderText + } + .padding(.bottom, 6) + #else + sectionHeaderText + .if(onboarding) { $0.padding(.bottom, 6) } + #endif + } + + @ViewBuilder private var sectionHeaderText: some View { + if onboarding { + Text("Send the link via any messenger - it's secure. Ask to paste into SimpleX.") + .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } else { + Text("Share this 1-time invite link").foregroundColor(theme.colors.secondary) + } + } + private func shareLinkView() -> some View { - HStack { + HStack(spacing: 8) { let link = connLinkInvitation.simplexChatUri(short: showShortLink) linkTextView(link) + Button { + UIPasteboard.general.string = link + setInvitationUsed() + } label: { + Image(systemName: "doc.on.doc") + .padding(.top, -7) + .padding(.horizontal, 8) + } Button { showShareSheet(items: [link]) setInvitationUsed() } label: { Image(systemName: "square.and.arrow.up") .padding(.top, -7) + .padding(.horizontal, 8) } } .frame(maxWidth: .infinity) @@ -324,7 +372,11 @@ private struct InviteView: View { .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } header: { - ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink) + if onboarding { + Text("Or show QR in person or via video call.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } else { + ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink) + } } } @@ -587,20 +639,24 @@ private struct ActiveProfilePicker: View { } private struct ConnectView: View { + @Environment(\.colorScheme) var colorScheme @StateObject private var connectProgressManager = ConnectProgressManager.shared @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var theme: AppTheme @Binding var showQRCodeScanner: Bool @Binding var pastedLink: String @Binding var alert: NewChatViewAlert? + var onboarding: Bool = false @State var scannerPaused: Bool = false @State private var pasteboardHasStrings = UIPasteboard.general.hasStrings var body: some View { List { - Section(header: Text("Paste the link you received").foregroundColor(theme.colors.secondary)) { + Section(header: connectSectionHeader) { pasteLinkView() } + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20)) + Section(header: Text("Or scan QR code").foregroundColor(theme.colors.secondary)) { ScannerInView(showQRCodeScanner: $showQRCodeScanner, scannerPaused: $scannerPaused, processQRCode: processQRCode) } @@ -630,7 +686,7 @@ private struct ConnectView: View { } } } label: { - Text("Tap to paste link") + Text("Tap to paste link").foregroundColor(theme.colors.primary) } .disabled(!pasteboardHasStrings) .frame(maxWidth: .infinity, alignment: .center) @@ -669,6 +725,23 @@ private struct ConnectView: View { } } + private var connectSectionHeader: some View { + #if SIMPLEX_ASSETS + VStack(alignment: .leading, spacing: 0) { + Image(colorScheme == .light + ? (onboarding ? "connect-via-link" : "connect-via-link-small") + : (onboarding ? "connect-via-link-light" : "connect-via-link-small-light")) + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + Text("Paste the link you received").foregroundColor(theme.colors.secondary) + } + .padding(.bottom, 4) + #else + Text("Paste the link you received").foregroundColor(theme.colors.secondary) + #endif + } + private func connect(_ link: String) { scannerPaused = true planAndConnect( @@ -765,7 +838,7 @@ struct ScannerInView: View { } -private func linkTextView(_ link: String) -> some View { +func linkTextView(_ link: String) -> some View { Text(link) .lineLimit(1) .font(.caption) diff --git a/apps/ios/Shared/Views/NewChat/OnboardingCards.swift b/apps/ios/Shared/Views/NewChat/OnboardingCards.swift new file mode 100644 index 0000000000..913fdf5577 --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/OnboardingCards.swift @@ -0,0 +1,309 @@ +// +// OnboardingCards.swift +// SimpleX (iOS) +// +// Created by simplex-chat on 06.04.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// MARK: - Card component + +struct OnboardingCardView: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + let imageName: String + let icon: String + let title: LocalizedStringKey + var subtitle: LocalizedStringKey? = nil + let labelHeightRatio: CGFloat + let action: () -> Void + + static let lightStops: [Gradient.Stop] = [ + .init(color: Color(red: 0.824, green: 0.910, blue: 1.0), location: 0.0), + .init(color: Color(red: 0.800, green: 0.914, blue: 1.0), location: 0.5), + .init(color: Color(red: 0.875, green: 1.0, blue: 1.0), location: 0.9), + .init(color: Color(red: 1.0, green: 0.988, blue: 0.918), location: 1.0) + ] + + static let darkStops: [Gradient.Stop] = [ + .init(color: Color(red: 0.016, green: 0.039, blue: 0.141), location: 0.4), + .init(color: Color(red: 0.220, green: 0.329, blue: 0.671), location: 0.72), + .init(color: Color(red: 0.659, green: 0.929, blue: 0.953), location: 0.9), + .init(color: Color(red: 1.0, green: 0.965, blue: 0.878), location: 1.0) + ] + + static let gradientAngle: Double = 80.0 * .pi / 180.0 + + static func gradientPoints(aspectRatio: CGFloat, scale: CGFloat) -> (start: UnitPoint, end: UnitPoint) { + let r = Double(aspectRatio) + let s = Double(scale) + let dx = cos(gradientAngle) + let dy = -sin(gradientAngle) / r + let dLenSq = dx * dx + dy * dy + let projections = [ + -0.5 * dx + (-0.5) * dy, + 0.5 * dx + (-0.5) * dy, + -0.5 * dx + 0.5 * dy, + 0.5 * dx + 0.5 * dy + ] + let tMin = projections.min()! + let tMax = projections.max()! + let startX = 0.5 + tMin * dx / dLenSq + let startY = 0.5 + tMin * dy / dLenSq + let endX = 0.5 + tMax * dx / dLenSq + let endY = 0.5 + tMax * dy / dLenSq + return ( + start: .init(x: 0.5 + (startX - 0.5) * s, y: 0.5 + (startY - 0.5) * s), + end: .init(x: 0.5 + (endX - 0.5) * s, y: 0.5 + (endY - 0.5) * s) + ) + } + + var body: some View { + Button(action: action) { + GeometryReader { geo in + let labelHeight = geo.size.width * labelHeightRatio + let imageHeight = max(geo.size.height - labelHeight, 1) + let imageAspect = imageHeight / geo.size.width + let gp = Self.gradientPoints(aspectRatio: imageAspect, scale: colorScheme == .light ? 1.2 : 1.5) + VStack(spacing: 0) { + ZStack { + LinearGradient( + stops: colorScheme == .light ? Self.lightStops : Self.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? imageName : "\(imageName)-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + #else + Image(systemName: icon) + .font(.system(size: imageHeight * 0.25)) + .foregroundColor(theme.colors.primary) + #endif + } + .frame(height: imageHeight) + + labelRow(height: labelHeight) + } + } + .clipShape(RoundedRectangle(cornerRadius: 24)) + } + .buttonStyle(.plain) + } + + private func labelRow(height: CGFloat) -> some View { + VStack { + HStack { + #if SIMPLEX_ASSETS + Image(systemName: icon) + .font(.system(size: 24)) + .foregroundColor(theme.colors.primary) + #endif + Text(title) + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + if let subtitle { + Text(subtitle) + .font(.footnote) + .foregroundColor(theme.colors.onBackground.opacity(0.7)) + } + } + .frame(height: height) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 16) + .background(ToolbarMaterial.material(toolbarMaterial)) + } +} + +// MARK: - Onboarding pager + +private let backButtonHeight: CGFloat = 44 + +struct ConnectOnboardingView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + @Environment(\.verticalSizeClass) private var verticalSizeClass + @State private var currentPage = 0 + @State private var showConnectViaLink = false + @State private var showInviteSomeone = false + @State private var showCreateAddress = false + + var body: some View { + TabView(selection: $currentPage) { + talkToSomeonePage.tag(0) + connectWithSomeonePage.tag(1) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + .sheet(isPresented: $showConnectViaLink) { + NavigationView { + NewChatView(selection: .connect, showQRCodeScanner: true, onboarding: true) + .modifier(ThemedBackground(grouped: true)) + } + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } + .sheet(isPresented: $showInviteSomeone) { + NavigationView { + NewChatView(selection: .invite, onboarding: true) + .modifier(ThemedBackground(grouped: true)) + } + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } + .sheet(isPresented: $showCreateAddress) { + NavigationView { + UserAddressView(autoCreate: true, onboarding: true) + .modifier(ThemedBackground(grouped: true)) + } + .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) + } + } + + @ViewBuilder + private func cardPair( + _ geo: GeometryProxy, + @ViewBuilder card1: () -> C1, + @ViewBuilder card2: () -> C2 + ) -> some View { + let padding: CGFloat = 20 + let spacing: CGFloat = 20 + let isLandscape = verticalSizeClass == .compact + let cardWidth = isLandscape + ? (geo.size.width - padding * 2 - spacing) / 2 + : geo.size.width - padding * 2 + let maxCardHeight = cardWidth * 0.75 + + if isLandscape { + HStack(spacing: spacing) { + card1().frame(maxHeight: maxCardHeight) + card2().frame(maxHeight: maxCardHeight) + } + .padding(.horizontal, padding) + } else { + VStack(spacing: spacing) { + card1().frame(maxHeight: maxCardHeight) + card2().frame(maxHeight: maxCardHeight) + } + .padding(.horizontal, padding) + } + } + + // MARK: Screen 1 + + @ViewBuilder + private func pageHeader(_ title: LocalizedStringKey, showBack: Bool) -> some View { + let isLandscape = verticalSizeClass == .compact + let titleView = Text(title) + .font(.largeTitle) + .bold() + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(maxWidth: .infinity, alignment: .center) + if isLandscape { + ZStack(alignment: .leading) { + if showBack { backButton } + titleView + } + .padding(.horizontal, 16) + } else { + VStack(spacing: 0) { + if showBack { + backButton.frame(maxWidth: .infinity, alignment: .leading) + } else { + Color.clear.frame(height: backButtonHeight) + } + titleView + } + .padding(.horizontal, 16) + } + } + + private var backButton: some View { + Button { + withAnimation { currentPage = 0 } + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Back") + } + } + .frame(height: backButtonHeight) + } + + // MARK: Screen 1 + + private var talkToSomeonePage: some View { + GeometryReader { geo in + VStack(spacing: 0) { + pageHeader("Talk to someone", showBack: false) + + Spacer(minLength: 16) + + cardPair(geo) { + OnboardingCardView( + imageName: "card-let-someone-connect-to-you-alpha", + icon: "link.badge.plus", + title: "Let someone connect to you", + labelHeightRatio: 0.132, + action: { withAnimation { currentPage = 1 } } + ) + } card2: { + OnboardingCardView( + imageName: "card-connect-via-link-alpha", + icon: "qrcode.viewfinder", + title: "Connect via link or QR code", + labelHeightRatio: 0.132, + action: { showConnectViaLink = true } + ) + } + + Spacer(minLength: 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: Screen 2 + + private var connectWithSomeonePage: some View { + GeometryReader { geo in + VStack(spacing: 0) { + pageHeader("Connect with someone", showBack: true) + + Spacer(minLength: 16) + + cardPair(geo) { + OnboardingCardView( + imageName: "card-invite-someone-privately-alpha", + icon: "link.badge.plus", + title: "Invite someone privately", + subtitle: "A link for one person to connect", + labelHeightRatio: 0.195, + action: { showInviteSomeone = true } + ) + } card2: { + OnboardingCardView( + imageName: "card-create-your-public-address-alpha", + icon: "qrcode", + title: m.userAddress != nil ? "Your public address" : "Create your public address", + subtitle: "For anyone to reach you", + labelHeightRatio: 0.195, + action: { showCreateAddress = true } + ) + } + + Spacer(minLength: 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift deleted file mode 100644 index f22d59fcac..0000000000 --- a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// AddressCreationCard.swift -// SimpleX (iOS) -// -// Created by Diogo Cunha on 13/11/2024. -// Copyright © 2024 SimpleX Chat. All rights reserved. -// -// Spec: spec/client/navigation.md - -import SwiftUI -import SimpleXChat - -struct AddressCreationCard: View { - @EnvironmentObject var theme: AppTheme - @EnvironmentObject private var chatModel: ChatModel - @Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize - @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false - @State private var showAddressCreationAlert = false - @State private var showAddressSheet = false - @State private var showAddressInfoSheet = false - - var body: some View { - let addressExists = chatModel.userAddress != nil - let chats = chatModel.chats.filter { chat in - !chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard - } - ZStack(alignment: .topTrailing) { - HStack(alignment: .top, spacing: 16) { - let envelopeSize = dynamicSize(userFont).profileImageSize - Image(systemName: "envelope.circle.fill") - .resizable() - .frame(width: envelopeSize, height: envelopeSize) - .foregroundColor(.accentColor) - VStack(alignment: .leading) { - Text("Your SimpleX address") - .font(.title3) - Spacer() - Text("How to use it") + textSpace + Text(Image(systemName: "info.circle")).foregroundColor(theme.colors.secondary) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - VStack(alignment: .trailing) { - Image(systemName: "multiply") - .foregroundColor(theme.colors.secondary) - .onTapGesture { - showAddressCreationAlert = true - } - Spacer() - Text("Create") - .foregroundColor(.accentColor) - .onTapGesture { - showAddressSheet = true - } - } - } - .onTapGesture { - showAddressInfoSheet = true - } - .padding() - .background(theme.appColors.sentMessage) - .cornerRadius(12) - .frame(height: dynamicSize(userFont).rowHeight) - .alert(isPresented: $showAddressCreationAlert) { - Alert( - title: Text("SimpleX address"), - message: Text("Tap Create SimpleX address in the menu to create it later."), - dismissButton: .default(Text("Ok")) { - withAnimation { - addressCreationCardShown = true - } - } - ) - } - .sheet(isPresented: $showAddressSheet) { - NavigationView { - UserAddressView(autoCreate: true) - .navigationTitle("SimpleX address") - .navigationBarTitleDisplayMode(.large) - .modifier(ThemedBackground(grouped: true)) - } - } - .sheet(isPresented: $showAddressInfoSheet) { - NavigationView { - UserAddressLearnMore(showCreateAddressButton: true) - .navigationTitle("Address or 1-time link?") - .navigationBarTitleDisplayMode(.inline) - .modifier(ThemedBackground(grouped: true)) - } - } - .onChange(of: addressExists) { exists in - if exists, !addressCreationCardShown { - addressCreationCardShown = true - } - } - .onChange(of: chats.count) { size in - if size >= 3, !addressCreationCardShown { - addressCreationCardShown = true - } - } - .onAppear { - if addressExists, !addressCreationCardShown { - addressCreationCardShown = true - } - } - } -} - -#Preview { - AddressCreationCard() -} diff --git a/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift b/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift new file mode 100644 index 0000000000..460ab9b141 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/ConnectBannerCard.swift @@ -0,0 +1,113 @@ +// +// ConnectBannerCard.swift +// SimpleX (iOS) +// +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +private let bannerImageRatio: CGFloat = 800 / 505 + +struct ConnectBannerCard: View { + @Environment(\.colorScheme) var colorScheme + @EnvironmentObject var theme: AppTheme + @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false + @State private var showNewLink = false + @State private var showPasteLink = false + + var body: some View { + VStack(alignment: .trailing, spacing: 3) { + Button { + withAnimation { addressCreationCardShown = true } + } label: { + Image(systemName: "multiply") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(theme.colors.secondary) + .frame(width: 30, height: 30) + .background(theme.colors.onBackground.opacity(0.08), in: Circle()) + } + HStack(spacing: 2) { + bannerHalf( + imageName: "banner-create-link", + icon: "link.badge.plus", + title: "New 1-time link", + action: { showNewLink = true } + ) + bannerHalf( + imageName: "banner-paste-link", + icon: "qrcode.viewfinder", + title: "Paste link / Scan", + action: { showPasteLink = true } + ) + } + .clipShape(RoundedRectangle(cornerRadius: 18)) + } + .sheet(isPresented: $showNewLink) { + NavigationView { + NewChatView(selection: .invite) + .modifier(ThemedBackground(grouped: true)) + } + } + .sheet(isPresented: $showPasteLink) { + NavigationView { + NewChatView(selection: .connect, showQRCodeScanner: true) + .modifier(ThemedBackground(grouped: true)) + } + } + } + + @ViewBuilder + private func bannerHalf(imageName: String, icon: String, title: LocalizedStringKey, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 0) { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? imageName : "\(imageName)-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + #else + gradientFallback(icon: icon) + #endif + HStack(spacing: 8) { + #if SIMPLEX_ASSETS + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundColor(theme.colors.primary) + #endif + Text(title) + .font(.footnote) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(ToolbarMaterial.material(toolbarMaterial)) + } + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func gradientFallback(icon: String) -> some View { + let gp = OnboardingCardView.gradientPoints( + aspectRatio: 1 / bannerImageRatio, + scale: colorScheme == .light ? 1.2 : 1.5 + ) + ZStack { + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + Image(systemName: icon) + .font(.system(size: 40)) + .foregroundColor(theme.colors.primary) + } + .aspectRatio(bannerImageRatio, contentMode: .fit) + .frame(maxWidth: .infinity) + } +} diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index d40ec116f4..4df58f8b0c 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -11,11 +11,13 @@ import MessageUI @preconcurrency import SimpleXChat struct UserAddressView: View { + @Environment(\.colorScheme) var colorScheme @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject private var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @State var shareViaProfile = false @State var autoCreate = false + var onboarding: Bool = false @State private var showShortLink = true @State private var settings = AddressSettingsState() @State private var savedSettings = AddressSettingsState() @@ -54,6 +56,14 @@ struct UserAddressView: View { } } } + .if(onboarding) { v in + v.toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Image(systemName: "info.circle").opacity(0) + } + } + .navigationBarTitleDisplayMode(.inline) + } .onAppear { if chatModel.userAddress == nil, autoCreate { createAddress() @@ -64,12 +74,16 @@ struct UserAddressView: View { private func userAddressView() -> some View { List { if let userAddress = chatModel.userAddress { - existingAddressView(userAddress) - .onAppear { - settings = AddressSettingsState(settings: userAddress.addressSettings) - savedSettings = AddressSettingsState(settings: userAddress.addressSettings) - } - } else { + if onboarding { + onboardingAddressView(userAddress) + } else { + existingAddressView(userAddress) + .onAppear { + settings = AddressSettingsState(settings: userAddress.addressSettings) + savedSettings = AddressSettingsState(settings: userAddress.addressSettings) + } + } + } else if !onboarding { Section { createAddressButton() } header: { @@ -121,8 +135,8 @@ struct UserAddressView: View { ) case .shareOnCreate: return Alert( - title: Text("Share address with contacts?"), - message: Text("Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts."), + title: Text("Share address with SimpleX contacts?"), + message: Text("Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts."), primaryButton: .default(Text("Share")) { setProfileAddress($progressIndicator, true) shareViaProfile = true @@ -157,7 +171,19 @@ struct UserAddressView: View { } addressSettingsButton(userAddress) } header: { + #if SIMPLEX_ASSETS + VStack(alignment: .leading, spacing: 0) { + Image(colorScheme == .light ? "simplex-address-small" : "simplex-address-small-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + .padding(.top, -20) + ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) + } + .padding(.bottom, 4) + #else ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink) + #endif } footer: { if settings.businessAddress { Text("Add your team members to the conversations.") @@ -184,6 +210,59 @@ struct UserAddressView: View { } } + @ViewBuilder private func onboardingAddressView(_ userAddress: UserContactLink) -> some View { + Section { + HStack(spacing: 8) { + let link = userAddress.connLinkContact.simplexChatUri(short: showShortLink) + linkTextView(link) + Button { UIPasteboard.general.string = link } label: { + Image(systemName: "doc.on.doc") + .padding(.top, -7) + .padding(.horizontal, 8) + } + Button { showShareSheet(items: [link]) } label: { + Image(systemName: "square.and.arrow.up") + .padding(.top, -7) + .padding(.horizontal, 8) + } + } + .frame(maxWidth: .infinity) + } header: { + #if SIMPLEX_ASSETS + VStack(alignment: .leading) { + Image(colorScheme == .light ? "simplex-address" : "simplex-address-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + Text("Use this address in your social media profile, website, or email signature.") + .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } + .padding(.bottom, 4) + #else + Text("Use this address in your social media profile, website, or email signature.") + .font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + .padding(.bottom, 6) + #endif + } + .listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 10)) + + Section { + SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink) + .id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))") + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(uiColor: .secondarySystemGroupedBackground)) + ) + .padding(.horizontal) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } header: { + Text("Or use this QR - print or show online.").font(.body).foregroundColor(theme.colors.onBackground).textCase(nil) + } + } + private func createAddressButton() -> some View { Button { createAddress() @@ -200,9 +279,22 @@ struct UserAddressView: View { DispatchQueue.main.async { if let connLinkContact { chatModel.userAddress = UserContactLink(connLinkContact) - alert = .shareOnCreate + let hasRelevantContacts = chatModel.chats.contains { chat in + if case let .direct(contact) = chat.chatInfo { + return contact.active && !contact.isContactCard && !contact.contactConnIncognito + } + return false + } + if hasRelevantContacts { + alert = .shareOnCreate + progressIndicator = false + } else { + setProfileAddress($progressIndicator, true) + shareViaProfile = true + } + } else { + progressIndicator = false } - progressIndicator = false } } catch let error { logger.error("UserAddressView apiCreateUserAddress: \(responseError(error))") @@ -486,15 +578,15 @@ struct UserAddressSettingsView: View { private func shareWithContactsButton() -> some View { settingsRow("person", color: theme.colors.secondary) { - Toggle("Share with contacts", isOn: $shareViaProfile) + Toggle("Share with SimpleX contacts", isOn: $shareViaProfile) .onChange(of: shareViaProfile) { on in if ignoreShareViaProfileChange { ignoreShareViaProfileChange = false } else { if on { showAlert( - NSLocalizedString("Share address with contacts?", comment: "alert title"), - message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"), + NSLocalizedString("Share address with SimpleX contacts?", comment: "alert title"), + message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"), actions: {[ UIAlertAction( title: NSLocalizedString("Cancel", comment: "alert action"), @@ -516,7 +608,7 @@ struct UserAddressSettingsView: View { } else { showAlert( NSLocalizedString("Stop sharing address?", comment: "alert title"), - message: NSLocalizedString("Profile update will be sent to your contacts.", comment: "alert message"), + message: NSLocalizedString("Profile update will be sent to your SimpleX contacts.", comment: "alert message"), actions: {[ UIAlertAction( title: NSLocalizedString("Cancel", comment: "alert action"), diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 7dd3a6c6ed..ac9bb3ff43 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -225,7 +225,6 @@ B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; }; B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; }; B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; }; - B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; }; CE176F202C87014C00145DBC /* InvertedForegroundStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */; }; CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; }; @@ -252,12 +251,16 @@ E559A0A12E3F77EE00B26F74 /* CommandsMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */; }; E5AEC0AB2F91A6EB00270665 /* CIChatLinkHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */; }; E5AEC0AF2F91A73500270665 /* ComposeChatLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */; }; + E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; + E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */; }; + E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */; }; E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; }; E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; }; E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9962C5906FF007928CC /* InfoPlist.strings */; }; E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */; }; E5DDBE702DC4217900A0EFF0 /* NSEAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */; }; + E5E418012F83D2CA00252B9E /* OnboardingCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E418002F83D2CA00252B9E /* OnboardingCards.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -596,7 +599,6 @@ B70CE9E52D4BE5930080F36D /* GroupMentions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMentions.swift; sourceTree = ""; }; B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = ""; }; B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = ""; }; - B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = ""; }; CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = ""; }; CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = ""; }; CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = ""; }; @@ -621,6 +623,10 @@ E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = ""; }; E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = ""; }; E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = ""; }; + E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = ""; }; + E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBannerCard.swift; sourceTree = ""; }; E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = ""; }; E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; @@ -675,6 +681,7 @@ E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = ""; }; E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAPITypes.swift; sourceTree = ""; }; E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEAPITypes.swift; sourceTree = ""; }; + E5E418002F83D2CA00252B9E /* OnboardingCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCards.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -876,6 +883,8 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */, + E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */, 5C55A92D283D0FDE00C4E99E /* sounds */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, @@ -901,6 +910,7 @@ 5C764E87279CBC8E000C6508 /* Model */, 5C2E260D27A30E2400F70299 /* Views */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, + E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */, D7AA2C3429A936B400737B40 /* MediaEncryption.playground */, 5C13730C2815740A00F43030 /* DebugJSON.playground */, ); @@ -947,7 +957,7 @@ 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */, 5CBD285B29575B8E00EC2CF4 /* WhatsNewView.swift */, 640743602CD360E600158442 /* ChooseServerOperators.swift */, - B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */, + E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */, ); path = Onboarding; sourceTree = ""; @@ -969,6 +979,7 @@ 640417CB2B29B8C200CCB412 /* NewChatMenuButton.swift */, 640417CC2B29B8C200CCB412 /* NewChatView.swift */, 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, + E5E418002F83D2CA00252B9E /* OnboardingCards.swift */, 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */, 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */, 647B15E72F4C8D2500EB431E /* AddChannelView.swift */, @@ -1236,6 +1247,7 @@ isa = PBXNativeTarget; buildConfigurationList = 5CA059F3279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (iOS)" */; buildPhases = ( + E5C0BBF02F82B50C00EA7527 /* Run Script */, 5CA059C6279559F40002BEB4 /* Sources */, 5CA059C7279559F40002BEB4 /* Frameworks */, 5CA059C8279559F40002BEB4 /* Resources */, @@ -1426,6 +1438,7 @@ 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, + E5C0BBE82F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1457,12 +1470,35 @@ buildActionMask = 2147483647; files = ( E5DCF9712C590272007928CC /* Localizable.strings in Resources */, + E5C0BBE92F82B45500EA7527 /* SimpleXAssets.xcassets in Resources */, E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + E5C0BBF02F82B50C00EA7527 /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "${SRCROOT}/../../scripts/ios/copy-assets.sh\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 5CA059C6279559F40002BEB4 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -1475,11 +1511,11 @@ 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */, 64A779FE2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift in Sources */, 5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */, + E5E418012F83D2CA00252B9E /* OnboardingCards.swift in Sources */, 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */, 64A779F82DBFDBF200FDEF2F /* MemberSupportView.swift in Sources */, 5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */, 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */, - B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */, 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */, E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */, 5C65DAF929D0CC20003CEE45 /* DeveloperView.swift in Sources */, @@ -1620,6 +1656,7 @@ 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 5C9329412929248A0090FFF9 /* ScanProtocolServer.swift in Sources */, 8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */, + E5DBF1932F88169800E1D7FD /* ConnectBannerCard.swift in Sources */, 64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, 5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */, @@ -1899,6 +1936,7 @@ /* Begin XCBuildConfiguration section */ 5CA059F1279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; @@ -1953,7 +1991,6 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -1961,6 +1998,7 @@ }; 5CA059F2279559F40002BEB4 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2bddf5b5b8..978e1d9630 100644 --- a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1", + "originHash" : "60aeecb7917535a5e44ade0dbb5411ab112a959283e565a04c212c8af4e7dee9", "pins" : [ { "identity" : "codescanner", diff --git a/apps/multiplatform/.gitignore b/apps/multiplatform/.gitignore index 5d39eb29f2..bc00225c87 100644 --- a/apps/multiplatform/.gitignore +++ b/apps/multiplatform/.gitignore @@ -16,4 +16,7 @@ android/build android/release common/build desktop/build -release \ No newline at end of file +release + +# Generated SimpleX assets +common/src/commonMain/resources/assets/simplex/ \ No newline at end of file diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index a2e1eb07c3..64dd34afbf 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -25,6 +25,9 @@ buildscript { extra.set("application_id.suffix", prop["application_id.suffix"] ?: "") // Compression level for debug AND release apk. 0 = disable compression. Max is 9 extra.set("compression.level", (prop["compression.level"] as String?)?.toIntOrNull() ?: 0) + if (prop["simplex.assets.dir"] != null) { + extra.set("simplex.assets.dir", prop["simplex.assets.dir"]) + } // NOTE: If you need a different version of something, provide it in `local.properties` // like so: compose.version=123, or gradle.plugin.version=1.2.3, etc diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index ea5579ed7d..65f0acd86c 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -11,6 +11,25 @@ plugins { group = "chat.simplex" version = extra["android.version_name"] as String +val simplexAssetsDir = rootProject.findProperty("simplex.assets.dir") as String? +val simplexAssetsLocal = file("src/commonMain/resources/assets/simplex") +val hasSimplexAssets = simplexAssetsDir != null + +if (simplexAssetsDir != null) { + val resolvedAssetsDir = rootProject.rootDir.resolve(simplexAssetsDir).absolutePath + tasks.register("copySimplexAssets") { + commandLine( + "${rootProject.rootDir}/../../scripts/android/copy-assets.sh", + resolvedAssetsDir, + simplexAssetsLocal.absolutePath + ) + } +} else { + tasks.register("cleanSimplexAssets") { + delete(simplexAssetsLocal) + } +} + kotlin { androidTarget() jvm("desktop") @@ -31,6 +50,11 @@ kotlin { } val commonMain by getting { + if (hasSimplexAssets) { + resources.srcDir(simplexAssetsLocal) + } else { + resources.srcDir("src/commonMain/resources/assets/default") + } dependencies { api(compose.runtime) api(compose.foundation) @@ -160,12 +184,18 @@ buildConfig { buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}") + buildConfigField("Boolean", "SIMPLEX_ASSETS", "$hasSimplexAssets") } } afterEvaluate { tasks.named("generateMRcommonMain") { dependsOn("adjustFormatting") + if (hasSimplexAssets) { + dependsOn("copySimplexAssets") + } else { + dependsOn("cleanSimplexAssets") + } } tasks.create("adjustFormatting") { doLast { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index d9439a5474..e1696fe37b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -34,6 +34,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.ModalManager.Companion.fromEndToStartTransition import chat.simplex.common.views.helpers.ModalManager.Companion.fromStartToEndTransition import chat.simplex.common.views.localauth.VerticalDivider +import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR @@ -383,7 +384,9 @@ fun CenterPartOfScreen() { } when (currentChatId.value) { null -> { - if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + if (shouldShowOnboarding()) { + ConnectOnboardingView() + } else if (!rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { Box( Modifier .fillMaxSize() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index ca002a8746..bc81c958e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1157,10 +1157,10 @@ object ChatModel { showingInvitation.value = null chatsContext.chatItems.clearAndNotify() chatModel.chatId.value = withId + ModalManager.start.closeModals() + ModalManager.end.closeModals() } } - ModalManager.start.closeModals() - ModalManager.end.closeModals() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index 1b5a81a819..1de47df7ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -626,6 +626,7 @@ val DEFAULT_BOTTOM_BUTTON_PADDING = 20.dp val DEFAULT_MIN_SECTION_ITEM_HEIGHT = 50.dp val DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL = 15.dp +val DEFAULT_WINDOW_WIDTH = 1366.dp val DEFAULT_START_MODAL_WIDTH = 388.dp val DEFAULT_MIN_CENTER_MODAL_WIDTH = 590.dp val DEFAULT_END_MODAL_WIDTH = 388.dp diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index d05b610aff..8f836c3c29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -24,7 +24,13 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize import chat.simplex.common.AppLock +import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts @@ -234,53 +240,120 @@ private fun ChatListCard( } } +private const val BANNER_IMAGE_RATIO = 800f / 505f + @Composable -private fun AddressCreationCard() { - ChatListCard( - close = { - appPrefs.addressCreationCardShown.set(true) - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.simplex_address), - text = generalGetString(MR.strings.address_creation_instruction), +private fun BannerGradientBox(isDark: Boolean, content: @Composable () -> Unit) { + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + val gp = gradientPoints(1f / BANNER_IMAGE_RATIO, scale) + var size by remember { mutableStateOf(IntSize.Zero) } + val brush = remember(size, isDark) { + if (size.width > 0 && size.height > 0) { + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * size.width, gp.startY * size.height), + end = Offset(gp.endX * size.width, gp.endY * size.height) ) - }, - onCardClick = { - ModalManager.start.showModal { - UserAddressLearnMore(showCreateAddressButton = true) - } + } else { + Brush.linearGradient(colorStops = stops) } - ) { - Box(modifier = Modifier.matchParentSize().padding(end = (DEFAULT_PADDING_HALF + 2.dp) * fontSizeSqrtMultiplier, bottom = 2.dp), contentAlignment = Alignment.BottomEnd) { - TextButton( - onClick = { - ModalManager.start.showModalCloseable { close -> - UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close) - } - }, - ) { - Text(stringResource(MR.strings.create_address_button), style = MaterialTheme.typography.body1) - } + } + Box( + Modifier.fillMaxWidth().aspectRatio(BANNER_IMAGE_RATIO).background(brush).onSizeChanged { size = it }, + contentAlignment = Alignment.Center + ) { content() } +} + +@Composable +private fun ConnectBannerCard() { + val isDark = isInDarkTheme() + val labelBg = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) + val buttonSize = 30.dp * fontSizeSqrtMultiplier + val gap = 3.dp * fontSizeSqrtMultiplier + + Column(horizontalAlignment = Alignment.End) { + IconButton( + onClick = { appPrefs.addressCreationCardShown.set(true) }, + modifier = Modifier.size(buttonSize) + ) { + Icon( + painterResource(MR.images.ic_close), + contentDescription = stringResource(MR.strings.icon_descr_close_button), + modifier = Modifier + .size(buttonSize) + .background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f), CircleShape) + .padding(buttonSize * 0.15f), + tint = MaterialTheme.colors.secondary + ) } + Spacer(Modifier.height(gap)) Row( Modifier .fillMaxWidth() - .padding(DEFAULT_PADDING), - verticalAlignment = Alignment.CenterVertically + .height(IntrinsicSize.Min) + .clip(RoundedCornerShape(18.dp)) ) { - Box(Modifier.padding(vertical = 4.dp)) { - Box(Modifier.background(MaterialTheme.colors.primary, CircleShape).padding(12.dp)) { - ProfileImage(size = 37.dp, null, icon = MR.images.ic_mail_filled, color = Color.White, backgroundColor = Color.Red) + Column( + Modifier.weight(1f).clickable { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) + } + } + ) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.banner_create_link_light else MR.images.banner_create_link), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() + ) + } else { + BannerGradientBox(isDark) { + Icon(painterResource(MR.images.ic_add_link), contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colors.primary) + } + } + Box(Modifier.fillMaxWidth().background(labelBg).padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(painterResource(MR.images.ic_add_link), contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.new_1_time_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } + } else { + Text(stringResource(MR.strings.new_1_time_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } } } - Column(modifier = Modifier.padding(start = DEFAULT_PADDING)) { - Text(stringResource(MR.strings.your_simplex_contact_address), style = MaterialTheme.typography.h3) - Spacer(Modifier.fillMaxWidth().padding(DEFAULT_PADDING_HALF)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text(stringResource(MR.strings.how_to_use_simplex_chat), Modifier.padding(end = DEFAULT_SPACE_AFTER_ICON), style = MaterialTheme.typography.body1) - Icon( - painterResource(MR.images.ic_info), - null, + Spacer(Modifier.width(2.dp).fillMaxHeight().background(MaterialTheme.colors.background)) + Column( + Modifier.weight(1f).clickable { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = close) + } + } + ) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.banner_paste_link_light else MR.images.banner_paste_link), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier.fillMaxWidth() ) + } else { + BannerGradientBox(isDark) { + Icon(painterResource(MR.images.ic_qr_code_scanner), contentDescription = null, modifier = Modifier.size(40.dp), tint = MaterialTheme.colors.primary) + } + } + Box(Modifier.fillMaxWidth().background(labelBg).padding(vertical = 8.dp), contentAlignment = Alignment.Center) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(painterResource(MR.images.ic_qr_code_scanner), contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colors.primary) + Text(stringResource(if (appPlatform.isAndroid) MR.strings.scan_paste_link else MR.strings.paste_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } + } else { + Text(stringResource(if (appPlatform.isAndroid) MR.strings.scan_paste_link else MR.strings.paste_link), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.onBackground) + } } } } @@ -289,15 +362,31 @@ private fun AddressCreationCard() { @Composable private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState, listState: LazyListState) { - if (!chatModel.desktopNoUserNoRemote) { - ChatList(searchText = searchText, listState) + if (chatModel.chatRunning.value == null) { + Text(stringResource(MR.strings.loading_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } else if (shouldShowOnboarding()) { + if (appPlatform.isAndroid) AndroidOnboardingCards() + } else { + if (!chatModel.desktopNoUserNoRemote) { + ChatList(searchText = searchText, listState) + } + if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { + Text(stringResource(MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } } - if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { - Text( - stringResource( - if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats - ), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary - ) +} + +@Composable +private fun AndroidOnboardingCards() { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val topPad = topPaddingToContent(false) + val bottomPad = if (oneHandUI.value) { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier + } else { + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + } + Box(Modifier.fillMaxSize().padding(top = topPad, bottom = bottomPad)) { + ConnectOnboardingView() } } @@ -454,31 +543,33 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow } }, title = { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)) { - Text( - stringResource(MR.strings.your_chats), - color = MaterialTheme.colors.onBackground, - fontWeight = FontWeight.SemiBold, - ) - SubscriptionStatusIndicator( - click = { - ModalManager.start.closeModals() - val summary = serversSummary.value - ModalManager.start.showModalCloseable( - endButtons = { - if (summary != null) { - ShareButton { - val json = Json { - prettyPrint = true + if (!shouldShowOnboarding()) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)) { + Text( + stringResource(MR.strings.your_chats), + color = MaterialTheme.colors.onBackground, + fontWeight = FontWeight.SemiBold, + ) + SubscriptionStatusIndicator( + click = { + ModalManager.start.closeModals() + val summary = serversSummary.value + ModalManager.start.showModalCloseable( + endButtons = { + if (summary != null) { + ShareButton { + val json = Json { + prettyPrint = true + } + val text = json.encodeToString(PresentedServersSummary.serializer(), summary) + clipboard.shareText(text) } - val text = json.encodeToString(PresentedServersSummary.serializer(), summary) - clipboard.shareText(text) } } - } - ) { ServersSummaryView(chatModel.currentRemoteHost.value, serversSummary) } - } - ) + ) { ServersSummaryView(chatModel.currentRemoteHost.value, serversSummary) } + } + ) + } } }, onTitleClick = if (canScrollToZero.value) { { scrollToBottom(scope, listState) } } else null, @@ -860,14 +951,6 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } } - if (!addressCreationCardShown.value) { - LaunchedEffect(chatModel.userAddress.value) { - if (chatModel.userAddress.value != null) { - appPrefs.addressCreationCardShown.set(true) - } - } - } - LaunchedEffect(activeFilter.value) { searchText.value = TextFieldValue("") } @@ -914,8 +997,8 @@ private fun ChatListFeatureCards() { if (!oneHandUICardShown.value && !oneHandUI.value) { ToggleChatListCard() } - if (!addressCreationCardShown.value) { - AddressCreationCard() + if (!addressCreationCardShown.value && hasConversations(chatModel.chats.value)) { + ConnectBannerCard() } if (!oneHandUICardShown.value && oneHandUI.value) { ToggleChatListCard() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt index 096b6c55ac..c7553b6ed0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/BlurModifier.kt @@ -64,7 +64,7 @@ private fun Modifier.androidBlurredModifier( } } .drawBehind { - drawRect(Color.Black) + drawRect(CurrentColors.value.colors.background) if (onTop) { clipRect { if (backgroundGraphicsLayer.size != IntSize.Zero) { @@ -110,7 +110,7 @@ private fun Modifier.desktopBlurredModifier( clip = blurRadius.value > 0 } .drawBehind { - drawRect(Color.Black) + drawRect(CurrentColors.value.colors.background) if (onTop) { clipRect { if (backgroundGraphicsLayer.size != IntSize.Zero) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index d197460e2e..fa27672270 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -27,6 +27,8 @@ import chat.simplex.common.views.* import chat.simplex.common.views.chat.group.GroupLinkView import chat.simplex.common.views.chatlist.openGroupChat import chat.simplex.common.views.usersettings.* +import androidx.compose.ui.layout.ContentScale +import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -99,22 +101,33 @@ fun AddGroupLayout( ) { ModalView(close = close) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId)) - Box( + AppBarTitle(stringResource(MR.strings.create_secret_group_title), hostDevice(rhId), bottomPadding = DEFAULT_PADDING_HALF) + Row( Modifier .fillMaxWidth() - .padding(bottom = 24.dp), - contentAlignment = Alignment.Center + .padding(vertical = DEFAULT_PADDING_HALF), + horizontalArrangement = if (BuildConfigCommon.SIMPLEX_ASSETS) Arrangement.SpaceEvenly else Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - Box(contentAlignment = Alignment.TopEnd) { - Box(contentAlignment = Alignment.Center) { - ProfileImage(108.dp, image = profileImage.value, icon = MR.images.ic_supervised_user_circle_filled) - EditImageButton { scope.launch { bottomSheetModalState.show() } } - } - if (profileImage.value != null) { - DeleteImageButton { profileImage.value = null } + Box(contentAlignment = Alignment.Center) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(128.dp, image = profileImage.value, icon = MR.images.ic_supervised_user_circle_filled) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } } } + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) MR.images.create_group_light else MR.images.create_group), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.height(140.dp) + ) + } } Row(Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index 292aa10f70..1eceaf4158 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -74,7 +74,7 @@ fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { Column(Modifier.align(Alignment.BottomCenter)) { DefaultAppBar( navigationButton = { NavigationButtonBack(onButtonClicked = close) }, - fixedTitleText = generalGetString(MR.strings.new_message), + fixedTitleText = generalGetString(MR.strings.new_chat), onTop = false, ) } @@ -359,7 +359,7 @@ private fun ModalData.NewChatSheetLayout( item { Box(Modifier.padding(top = blankSpaceSize)) { AppBarTitle( - stringResource(MR.strings.new_message), + stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index f520a86999..72311cd7fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -21,9 +21,11 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp @@ -39,6 +41,7 @@ import chat.simplex.common.views.chat.item.CIFileViewScope import chat.simplex.common.views.chat.topPaddingToContent import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* +import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR import kotlinx.coroutines.* @@ -47,7 +50,7 @@ enum class NewChatOption { } @Composable -fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, close: () -> Unit) { +fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRCodeScanner: Boolean = false, onboarding: Boolean = false, close: () -> Unit) { val selection = remember { stateGetOrPut("selection") { selection } } val showQRCodeScanner = remember { stateGetOrPut("showQRCodeScanner") { showQRCodeScanner } } val contactConnection: MutableState = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(chatModel.showingInvitation.value?.conn) } @@ -104,60 +107,71 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC } } - BoxWithConstraints { + if (onboarding) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING) - val scope = rememberCoroutineScope() - val pagerState = rememberPagerState( - initialPage = selection.value.ordinal, - initialPageOffsetFraction = 0f - ) { NewChatOption.values().size } - KeyChangeEffect(pagerState.currentPage) { - selection.value = NewChatOption.values()[pagerState.currentPage] + Spacer(Modifier.height(DEFAULT_PADDING)) + when (selection.value) { + NewChatOption.INVITE -> PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq, onboarding = true) + NewChatOption.CONNECT -> ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close, onboarding = true) } - TabRow( - selectedTabIndex = pagerState.currentPage, - backgroundColor = Color.Transparent, - contentColor = MaterialTheme.colors.primary, - ) { - tabTitles.forEachIndexed { index, it -> - LeadingIconTab( - selected = pagerState.currentPage == index, - onClick = { - scope.launch { - pagerState.animateScrollToPage(index) - } - }, - text = { Text(it, fontSize = 13.sp) }, - icon = { - Icon( - if (NewChatOption.INVITE.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_qr_code), - it - ) - }, - selectedContentColor = MaterialTheme.colors.primary, - unselectedContentColor = MaterialTheme.colors.secondary, - ) + SectionBottomSpacer() + } + } else { + BoxWithConstraints { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING) + val scope = rememberCoroutineScope() + val pagerState = rememberPagerState( + initialPage = selection.value.ordinal, + initialPageOffsetFraction = 0f + ) { NewChatOption.values().size } + KeyChangeEffect(pagerState.currentPage) { + selection.value = NewChatOption.values()[pagerState.currentPage] } - } - - HorizontalPager(state = pagerState, Modifier, pageNestedScrollConnection = LocalAppBarHandler.current!!.connection, verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index -> - Column( - Modifier - .fillMaxWidth() - .heightIn(min = this@BoxWithConstraints.maxHeight - 150.dp), - verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connLinkInvitation.connFullLink.isEmpty()) Arrangement.Center else Arrangement.Top + TabRow( + selectedTabIndex = pagerState.currentPage, + backgroundColor = Color.Transparent, + contentColor = MaterialTheme.colors.primary, ) { - Spacer(Modifier.height(DEFAULT_PADDING)) - when (index) { - NewChatOption.INVITE.ordinal -> { - PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq) - } - NewChatOption.CONNECT.ordinal -> { - ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close) - } + tabTitles.forEachIndexed { index, it -> + LeadingIconTab( + selected = pagerState.currentPage == index, + onClick = { + scope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(it, fontSize = 13.sp) }, + icon = { + Icon( + if (NewChatOption.INVITE.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_qr_code), + it + ) + }, + selectedContentColor = MaterialTheme.colors.primary, + unselectedContentColor = MaterialTheme.colors.secondary, + ) + } + } + + HorizontalPager(state = pagerState, Modifier, pageNestedScrollConnection = LocalAppBarHandler.current!!.connection, verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index -> + Column( + Modifier + .fillMaxWidth() + .heightIn(min = this@BoxWithConstraints.maxHeight - 150.dp), + verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connLinkInvitation.connFullLink.isEmpty()) Arrangement.Center else Arrangement.Top + ) { + Spacer(Modifier.height(DEFAULT_PADDING)) + when (index) { + NewChatOption.INVITE.ordinal -> { + PrepareAndInviteView(rh?.remoteHostId, contactConnection, connLinkInvitation, creatingConnReq) + } + NewChatOption.CONNECT.ordinal -> { + ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close) + } + } + SectionBottomSpacer() } - SectionBottomSpacer() } } } @@ -165,12 +179,13 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC } @Composable -private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState) { +private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState, onboarding: Boolean = false) { if (connLinkInvitation.connFullLink.isNotEmpty()) { InviteView( rhId, connLinkInvitation = connLinkInvitation, contactConnection = contactConnection, + onboarding = onboarding, ) } else if (creatingConnReq.value) { CreatingLinkProgressView() @@ -448,23 +463,53 @@ fun ActiveProfilePicker( } @Composable -private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState) { +private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState, onboarding: Boolean = false) { val showShortLink = remember { mutableStateOf(true) } - Spacer(Modifier.height(10.dp)) - SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) { + if (onboarding) MR.images.one_time_link_light else MR.images.one_time_link_small_light + } else { + if (onboarding) MR.images.one_time_link else MR.images.one_time_link_small + }), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } else { + Spacer(Modifier.height(10.dp)) + } + + if (onboarding) { + Text( + stringResource(MR.strings.onboarding_send_1_time_link), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) - } - - Spacer(Modifier.height(DEFAULT_PADDING)) - - SectionViewWithButton( - stringResource(MR.strings.or_show_this_qr_code).uppercase(), - titleButton = if (connLinkInvitation.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null - ) { + Text( + stringResource(MR.strings.onboarding_or_show_qr_code), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) + } else { + SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) + } + + Spacer(Modifier.height(DEFAULT_PADDING)) + + SectionViewWithButton( + stringResource(MR.strings.or_show_this_qr_code).uppercase(), + titleButton = if (connLinkInvitation.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) + } } + if (!onboarding) { Spacer(Modifier.height(DEFAULT_PADDING)) val incognito by remember(chatModel.showingInvitation.value?.conn?.incognito, controller.appPrefs.incognito.get()) { derivedStateOf { @@ -531,6 +576,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact SectionTextFooter(generalGetString(MR.strings.connect__a_new_random_profile_will_be_shared)) } } + } } @Composable @@ -577,13 +623,26 @@ fun AddContactLearnMoreButton() { } @Composable -private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, pastedLink: MutableState, close: () -> Unit) { +private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, pastedLink: MutableState, close: () -> Unit, onboarding: Boolean = false) { DisposableEffect(Unit) { onDispose { connectProgressManager.cancelConnectProgress() } } + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isInDarkTheme()) { + if (onboarding) MR.images.connect_via_link_light else MR.images.connect_via_link_small_light + } else { + if (onboarding) MR.images.connect_via_link else MR.images.connect_via_link_small + }), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } + SectionView(stringResource(MR.strings.paste_the_link_you_received).uppercase(), headerBottomPadding = 5.dp) { PasteLinkView(rhId, pastedLink, showQRCodeScanner, close) } @@ -625,7 +684,7 @@ private fun PasteLinkView(rhId: Long?, pastedLink: MutableState, showQRC } }) { Box(Modifier.weight(1f)) { - Text(stringResource(MR.strings.tap_to_paste_link)) + Text(stringResource(MR.strings.tap_to_paste_link), color = MaterialTheme.colors.primary) } if (connectProgressManager.showConnectProgress != null) { CIFileViewScope.progressIndicator(sizeMultiplier = 0.6f) @@ -681,6 +740,13 @@ fun LinkTextView(link: String, share: Boolean) { // So using BasicTextField + manual ... Text("…", fontSize = 16.sp) if (share) { + Spacer(Modifier.width(DEFAULT_PADDING)) + IconButton({ + chatModel.markShowingInvitationUsed() + clipboard.setText(AnnotatedString(link)) + }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_content_copy), null, tint = MaterialTheme.colors.primary) + } Spacer(Modifier.width(DEFAULT_PADDING)) IconButton({ chatModel.markShowingInvitationUsed() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt new file mode 100644 index 0000000000..98954eb74f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt @@ -0,0 +1,422 @@ +package chat.simplex.common.views.newchat + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.BuildConfigCommon +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.UserAddressView +import chat.simplex.res.MR +import kotlinx.coroutines.launch +import kotlin.math.cos +import kotlin.math.sin + +private const val CARD_HEIGHT_RATIO = 0.75f +private const val GRADIENT_ANGLE_RAD = 80.0 * Math.PI / 180.0 + +@Composable +fun shouldShowOnboarding(): Boolean { + val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val chats = chatModel.chats.value + return !addressCreationCardShown.value && chats.isNotEmpty() && !hasConversations(chats) +} + +fun hasConversations(chats: List): Boolean = + chats.any { chat -> + when (val c = chat.chatInfo) { + is ChatInfo.Local -> false + is ChatInfo.Direct -> !c.contact.chatDeleted && !c.contact.isContactCard + is ChatInfo.Group -> true + is ChatInfo.ContactRequest -> false + is ChatInfo.ContactConnection -> false + is ChatInfo.InvalidJSON -> false + } + } + +internal data class GradientEndpoints(val startX: Float, val startY: Float, val endX: Float, val endY: Float) + +internal fun gradientPoints(aspectRatio: Float, scale: Float): GradientEndpoints { + val r = aspectRatio.toDouble() + val s = scale.toDouble() + val dx = cos(GRADIENT_ANGLE_RAD) + val dy = -sin(GRADIENT_ANGLE_RAD) / r + val dLenSq = dx * dx + dy * dy + val projections = doubleArrayOf( + -0.5 * dx + (-0.5) * dy, + 0.5 * dx + (-0.5) * dy, + -0.5 * dx + 0.5 * dy, + 0.5 * dx + 0.5 * dy + ) + val tMin = projections.min() + val tMax = projections.max() + val startX = 0.5 + tMin * dx / dLenSq + val startY = 0.5 + tMin * dy / dLenSq + val endX = 0.5 + tMax * dx / dLenSq + val endY = 0.5 + tMax * dy / dLenSq + return GradientEndpoints( + startX = (0.5 + (startX - 0.5) * s).toFloat(), + startY = (0.5 + (startY - 0.5) * s).toFloat(), + endX = (0.5 + (endX - 0.5) * s).toFloat(), + endY = (0.5 + (endY - 0.5) * s).toFloat() + ) +} + +internal val lightStops = arrayOf( + 0.0f to Color(0xFFd2e8ff), + 0.5f to Color(0xFFcce9ff), + 0.9f to Color(0xFFdfffff), + 1.0f to Color(0xFFfffcea) +) + +internal val darkStops = arrayOf( + 0.4f to Color(0xFF040a24), + 0.72f to Color(0xFF3854ab), + 0.9f to Color(0xFFa8edf3), + 1.0f to Color(0xFFfff6e0) +) + +private fun Modifier.maxHeightByWidthRatio(ratio: Float) = layout { measurable, constraints -> + val maxH = (constraints.maxWidth * ratio).toInt().coerceAtMost(constraints.maxHeight) + val placeable = measurable.measure(constraints.copy(minHeight = 0, maxHeight = maxH)) + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } +} + +@Composable +fun OnboardingCardView( + imageName: dev.icerock.moko.resources.ImageResource, + imageNameLight: dev.icerock.moko.resources.ImageResource, + icon: dev.icerock.moko.resources.ImageResource, + title: String, + subtitle: String? = null, + labelHeightRatio: Float, + onClick: () -> Unit +) { + var imageAreaSize by remember { mutableStateOf(IntSize.Zero) } + val isDark = isInDarkTheme() + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + + val brush = remember(imageAreaSize, isDark) { + if (imageAreaSize.width > 0 && imageAreaSize.height > 0) { + val aspect = imageAreaSize.height.toFloat() / imageAreaSize.width.toFloat() + val gp = gradientPoints(aspect, scale) + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * imageAreaSize.width, gp.startY * imageAreaSize.height), + end = Offset(gp.endX * imageAreaSize.width, gp.endY * imageAreaSize.height) + ) + } else { + Brush.linearGradient(colorStops = stops) + } + } + + val labelBg = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) + + Box( + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(24.dp)) + .clickable(onClick = onClick) + ) { + Column(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxWidth() + .weight(1f) + .background(brush) + .onSizeChanged { imageAreaSize = it } + ) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) imageNameLight else imageName), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + } else { + Icon( + painterResource(icon), + contentDescription = null, + modifier = Modifier.size(64.dp).align(Alignment.Center), + tint = MaterialTheme.colors.primary + ) + } + } + Box( + Modifier + .fillMaxWidth() + .aspectRatio(1f / labelHeightRatio) + .background(labelBg), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Icon( + painterResource(icon), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colors.primary + ) + } + Text( + title, + style = (if (appPlatform.isDesktop) MaterialTheme.typography.h3 else MaterialTheme.typography.h4).copy(fontWeight = FontWeight.Medium), + color = MaterialTheme.colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (subtitle != null) { + Text( + subtitle, + style = if (appPlatform.isDesktop) MaterialTheme.typography.body1 else MaterialTheme.typography.body2, + color = MaterialTheme.colors.onBackground.copy(alpha = 0.7f) + ) + } + } + } + } + } +} + +@Composable +private fun PageHeader(title: String, isLandscape: Boolean, onBack: (() -> Unit)? = null) { + val color = if (onBack != null) MaterialTheme.colors.primary else Color.Transparent + val baseStyle = MaterialTheme.typography.h1 + val titleView = @Composable { + var fontScale by remember(title) { mutableStateOf(1f) } + Text( + title, + style = baseStyle.copy(fontSize = baseStyle.fontSize * fontScale), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + onTextLayout = { result -> + if (result.hasVisualOverflow && fontScale > 0.5f) { + fontScale -= 0.05f + } + } + ) + } + if (isLandscape) { + Box(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING)) { + BackButton(Modifier.align(Alignment.CenterStart), color, onBack) + titleView() + } + } else { + Column(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING)) { + Box(Modifier.align(Alignment.Start)) { + BackButton(color = color, onClick = onBack) + } + titleView() + } + } +} + +@Composable +private fun BackButton(modifier: Modifier = Modifier, color: Color = MaterialTheme.colors.primary, onClick: (() -> Unit)? = null) { + Row( + modifier + .clip(RoundedCornerShape(20.dp)) + .clickable(enabled = onClick != null, onClick = onClick ?: {}) + .padding(end = 12.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + painterResource(MR.images.ic_arrow_back_ios_new), + contentDescription = stringResource(MR.strings.back), + tint = color, + modifier = Modifier.height(24.dp) + ) + Text(stringResource(MR.strings.back), color = color) + } +} + +@Composable +private fun CardPair( + isLandscape: Boolean, + heightRatio: Float, + card1: @Composable () -> Unit, + card2: @Composable () -> Unit +) { + if (isLandscape) { + Row( + Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.weight(1f).maxHeightByWidthRatio(heightRatio)) { card1() } + Box(Modifier.weight(1f).maxHeightByWidthRatio(heightRatio)) { card2() } + } + } else { + Column( + Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING), + verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING, Alignment.CenterVertically) + ) { + Box(Modifier.fillMaxWidth().weight(1f, fill = false).maxHeightByWidthRatio(heightRatio)) { card1() } + Box(Modifier.fillMaxWidth().weight(1f, fill = false).maxHeightByWidthRatio(heightRatio)) { card2() } + } + } +} + +@Composable +private fun OnboardingPageLayout( + title: String, + onBack: (() -> Unit)? = null, + cards: @Composable (isLandscape: Boolean) -> Unit +) { + val isLandscape = appPlatform.isDesktop || windowOrientation() == WindowOrientation.LANDSCAPE + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + PageHeader(title = title, isLandscape = isLandscape, onBack = onBack) + Box(Modifier.weight(1f).fillMaxWidth().padding(vertical = DEFAULT_PADDING)) { + cards(isLandscape) + } + } +} + +@Composable +fun ConnectOnboardingView() { + val pagerState = rememberPagerState(initialPage = 0) { 2 } + val scope = rememberCoroutineScope() + + val startModalsOpen = appPlatform.isDesktop && ModalManager.start.hasModalsOpen + val cardAlpha by animateFloatAsState(if (startModalsOpen) 0.3f else 1f) + + val cardClickOverride: (() -> Unit)? = if (startModalsOpen) { + { ModalManager.start.closeModals() } + } else null + + fun goToPage(target: Int) { + if (appPlatform.isDesktop) { + scope.launch { pagerState.scrollToPage(target) } + } else { + scope.launch { pagerState.animateScrollToPage(target, animationSpec = tween(350)) } + } + } + + val pager = @Composable { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxWidth(), + userScrollEnabled = !appPlatform.isDesktop + ) { page -> + when (page) { + 0 -> OnboardingPageLayout(title = stringResource(MR.strings.talk_to_someone)) { isLandscape -> + CardPair(isLandscape, CARD_HEIGHT_RATIO, + card1 = { + OnboardingCardView( + imageName = MR.images.card_let_someone_connect_to_you_alpha, + imageNameLight = MR.images.card_let_someone_connect_to_you_alpha_light, + icon = MR.images.ic_add_link, + title = stringResource(MR.strings.let_someone_connect_to_you), + labelHeightRatio = 0.132f, + onClick = cardClickOverride ?: { goToPage(1) } + ) + }, + card2 = { + OnboardingCardView( + imageName = MR.images.card_connect_via_link_alpha, + imageNameLight = MR.images.card_connect_via_link_alpha_light, + icon = MR.images.ic_qr_code_scanner, + title = stringResource(MR.strings.connect_via_link_or_qr_code), + labelHeightRatio = 0.132f, + onClick = cardClickOverride ?: { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, onboarding = true, close = close) + } + } + ) + } + ) + } + 1 -> OnboardingPageLayout( + title = stringResource(MR.strings.connect_with_someone), + onBack = cardClickOverride ?: { goToPage(0) } + ) { isLandscape -> + CardPair(isLandscape, CARD_HEIGHT_RATIO, + card1 = { + OnboardingCardView( + imageName = MR.images.card_invite_someone_privately_alpha, + imageNameLight = MR.images.card_invite_someone_privately_alpha_light, + icon = MR.images.ic_add_link, + title = stringResource(MR.strings.invite_someone_privately), + subtitle = stringResource(MR.strings.a_link_for_one_person), + labelHeightRatio = 0.195f, + onClick = cardClickOverride ?: { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, onboarding = true, close = close) + } + } + ) + }, + card2 = { + OnboardingCardView( + imageName = MR.images.card_create_your_public_address_alpha, + imageNameLight = MR.images.card_create_your_public_address_alpha_light, + icon = MR.images.ic_qr_code, + title = stringResource(if (chatModel.userAddress.value != null) MR.strings.your_public_address else MR.strings.create_your_public_address), + subtitle = stringResource(MR.strings.for_anyone_to_reach_you), + labelHeightRatio = 0.195f, + onClick = cardClickOverride ?: { + ModalManager.start.showModalCloseable { close -> + UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, onboarding = true, close = close) + } + } + ) + } + ) + } + } + } + } + + if (appPlatform.isDesktop) { + val maxContentWidth = DEFAULT_WINDOW_WIDTH - DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + Box( + Modifier.fillMaxSize().background(MaterialTheme.colors.background).padding(vertical = DEFAULT_PADDING).graphicsLayer { alpha = cardAlpha }, + contentAlignment = Alignment.Center + ) { + Box(Modifier.widthIn(max = maxContentWidth).fillMaxHeight()) { + pager() + } + } + } else { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + pager() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 3b6cf34b7c..e5c731f3b2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -7,7 +7,9 @@ import SectionTextFooter import SectionView import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* +import androidx.compose.ui.layout.ContentScale import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.* @@ -28,6 +30,7 @@ import chat.simplex.common.model.MsgContent import chat.simplex.common.platform.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.newchat.* +import chat.simplex.common.BuildConfigCommon import chat.simplex.res.MR @Composable @@ -35,6 +38,7 @@ fun UserAddressView( chatModel: ChatModel, shareViaProfile: Boolean = false, autoCreateAddress: Boolean = false, + onboarding: Boolean = false, close: () -> Unit ) { // TODO close when remote host changes @@ -75,17 +79,31 @@ fun UserAddressView( addressSettings = AddressSettings(businessAddress = false, autoAccept = null, autoReply = null) ) - AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.share_address_with_contacts_question), - text = generalGetString(MR.strings.add_address_to_your_profile), - confirmText = generalGetString(MR.strings.share_verb), - onConfirm = { - setProfileAddress(true) - shareViaProfile.value = true - } - ) + val hasRelevantContacts = chatModel.chats.value.any { chat -> + val ci = chat.chatInfo + ci is ChatInfo.Direct && + ci.contact.active && + !ci.contact.isContactCard && + !ci.contact.contactConnIncognito + } + if (hasRelevantContacts) { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.share_address_with_contacts_question), + text = generalGetString(MR.strings.add_address_to_your_profile), + confirmText = generalGetString(MR.strings.share_verb), + onConfirm = { + setProfileAddress(true) + shareViaProfile.value = true + } + ) + progressIndicator.value = false + } else { + setProfileAddress(true) + shareViaProfile.value = true + } + } else { + progressIndicator.value = false } - progressIndicator.value = false } } @@ -103,6 +121,7 @@ fun UserAddressView( user = user.value, userAddress = userAddress.value, shareViaProfile, + onboarding = onboarding, createAddress = ::createAddress, showAddShortLinkAlert = { shareAddress: (() -> Unit)? -> showAddShortLinkAlert(progressIndicator = progressIndicator, share = ::share, shareAddress = shareAddress) @@ -249,6 +268,7 @@ private fun UserAddressLayout( user: User?, userAddress: UserContactLinkRec?, shareViaProfile: MutableState, + onboarding: Boolean = false, createAddress: () -> Unit, showAddShortLinkAlert: ((() -> Unit)?) -> Unit, learnMore: () -> Unit, @@ -259,68 +279,100 @@ private fun UserAddressLayout( saveAddressSettings: (AddressSettingsState, MutableState) -> Unit, ) { ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId)) + if (!onboarding) { + AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId)) + } + if (BuildConfigCommon.SIMPLEX_ASSETS && userAddress != null) { + Image( + painterResource(if (isInDarkTheme()) { + if (onboarding) MR.images.simplex_address_light else MR.images.simplex_address_small_light + } else { + if (onboarding) MR.images.simplex_address else MR.images.simplex_address_small + }), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxWidth() + ) + } Column( Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceEvenly ) { if (userAddress == null) { - SectionView(generalGetString(MR.strings.for_social_media).uppercase()) { - CreateAddressButton(createAddress) - } + if (!onboarding) { + SectionView(generalGetString(MR.strings.for_social_media).uppercase()) { + CreateAddressButton(createAddress) + } - SectionDividerSpaced() - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { - CreateOneTimeLinkButton() - } + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + CreateOneTimeLinkButton() + } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) - SectionView { - LearnMoreButton(learnMore) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionView { + LearnMoreButton(learnMore) + } } } else { - val addressSettingsState = remember { mutableStateOf(AddressSettingsState(settings = userAddress.addressSettings)) } - val savedAddressSettingsState = remember { mutableStateOf(addressSettingsState.value) } val showShortLink = remember { mutableStateOf(true) } - SectionViewWithButton( - stringResource(MR.strings.for_social_media).uppercase(), - titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null - ) { + if (onboarding) { + Text( + stringResource(MR.strings.onboarding_post_address), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) + LinkTextView(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value), true) + Text( + stringResource(MR.strings.onboarding_or_use_qr_code), + Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), + style = MaterialTheme.typography.body1 + ) SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) - if (userAddress.shouldBeUpgraded) { - AddShortLinkButton(text = stringResource(MR.strings.add_short_link)) { showAddShortLinkAlert(null) } - } - ShareAddressButton { + } else { + val addressSettingsState = remember { mutableStateOf(AddressSettingsState(settings = userAddress.addressSettings)) } + val savedAddressSettingsState = remember { mutableStateOf(addressSettingsState.value) } + + SectionViewWithButton( + stringResource(MR.strings.for_social_media).uppercase(), + titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null + ) { + SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) if (userAddress.shouldBeUpgraded) { - showAddShortLinkAlert { share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) } - } else { - share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) + AddShortLinkButton(text = stringResource(MR.strings.add_short_link)) { showAddShortLinkAlert(null) } + } + ShareAddressButton { + if (userAddress.shouldBeUpgraded) { + showAddShortLinkAlert { share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) } + } else { + share(userAddress.connLinkContact.simplexChatUri(short = showShortLink.value)) + } + } + // ShareViaEmailButton { sendEmail(userAddress) } + BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } + AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings) + + if (addressSettingsState.value.businessAddress) { + SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) } } - // ShareViaEmailButton { sendEmail(userAddress) } - BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } - AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings) - if (addressSettingsState.value.businessAddress) { - SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) + SectionDividerSpaced(maxTopPadding = addressSettingsState.value.businessAddress) + SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + CreateOneTimeLinkButton() + } + SectionDividerSpaced(maxBottomPadding = false) + SectionView { + LearnMoreButton(learnMore) } - } - SectionDividerSpaced(maxTopPadding = addressSettingsState.value.businessAddress) - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { - CreateOneTimeLinkButton() - } - SectionDividerSpaced(maxBottomPadding = false) - SectionView { - LearnMoreButton(learnMore) - } - - SectionDividerSpaced(maxBottomPadding = false) - SectionView { - DeleteAddressButton(deleteAddress) - SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) + SectionDividerSpaced(maxBottomPadding = false) + SectionView { + DeleteAddressButton(deleteAddress) + SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) + } } } } diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 1477e4f607..ea03fa8286 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -465,6 +465,15 @@ Tap to start a new chat Chat with the developers You have no chats + Talk to someone + Let someone connect to you + Connect via link or QR code + Connect with someone + Invite someone privately + A link for one person to connect + Create your public address + Your public address + For anyone to reach you Loading chats… No filtered chats No chats in list %s. @@ -904,7 +913,7 @@ New chat New message Add contact - Scan / Paste link + Paste link / Scan Paste link One-time invitation link 1-time link @@ -1132,12 +1141,12 @@ All your contacts will remain connected. All your contacts will remain connected. Profile update will be sent to your contacts. Share link - Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts. + Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts. Create an address to let people connect with you. Create SimpleX address - Share with contacts - Share address with contacts? - Profile update will be sent to your contacts. + Share with SimpleX contacts + Share address with SimpleX contacts? + Profile update will be sent to your SimpleX contacts. Stop sharing address? Stop sharing Auto-accept @@ -1154,6 +1163,11 @@ Or to share privately SimpleX address or 1-time link? Create 1-time link + New 1-time link + Send the link via any messenger - it\'s secure. Ask to paste into SimpleX. + Or show QR in person or via video call. + Use this address in your social media profile, website, or email signature. + Or use this QR - print or show online. Address settings Business address Add your team members to the conversations. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg new file mode 100644 index 0000000000..6d012c8956 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_qr_code_scanner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_create_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/banner_paste_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_connect_via_link_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_create_your_public_address_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_invite_someone_privately_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/card_let_someone_connect_to_you_alpha_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/connect_via_link_small_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/create_group_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/one_time_link_small_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg new file mode 100644 index 0000000000..2325330d90 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/simplex_address_small_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt index 2a1a26df95..e4866c845d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt @@ -3,11 +3,12 @@ package chat.simplex.common import chat.simplex.common.model.json import chat.simplex.common.platform.appPreferences import chat.simplex.common.platform.desktopPlatform +import chat.simplex.common.ui.theme.DEFAULT_WINDOW_WIDTH import kotlinx.serialization.* @Serializable data class WindowPositionSize( - val width: Int = 1366, + val width: Int = DEFAULT_WINDOW_WIDTH.value.toInt(), val height: Int = 768, val x: Int = 0, val y: Int = 0, diff --git a/apps/multiplatform/local.properties.example b/apps/multiplatform/local.properties.example index 8fa9a47963..9aa560d839 100644 --- a/apps/multiplatform/local.properties.example +++ b/apps/multiplatform/local.properties.example @@ -3,6 +3,8 @@ enable_debuggable=true application_id.suffix=.debug app.name=SimpleX Debug +#simplex.assets.dir=path/to/assets + #desktop.mac.signing.identity=SimpleX Chat Ltd #desktop.mac.signing.keychain=/path/to/simplex.keychain #desktop.mac.notarization.apple_id=example@example.com diff --git a/plans/2026-04-06-onboarding-cards-compose.md b/plans/2026-04-06-onboarding-cards-compose.md new file mode 100644 index 0000000000..00df159eee --- /dev/null +++ b/plans/2026-04-06-onboarding-cards-compose.md @@ -0,0 +1,492 @@ +# Onboarding Cards — Compose (Android/Desktop) Implementation Plan + +References the layout specification in `plans/2026-04-06-onboarding-cards-ios.md`. + +## Scope + +Same as iOS: Screens 1 and 2 with paging transition. Modal sheets for deeper views. No banner, no standalone onboarding variants. + +## New file + +`common/src/commonMain/kotlin/chat/simplex/common/views/newchat/OnboardingCards.kt` + +## Assets + +8 card stub SVGs needed in `assets/default/MR/images/` (same names as the real PNGs, with `.svg` extension): +- `card_let_someone_connect_to_you_alpha.svg` / `_light.svg` +- `card_connect_via_link_alpha.svg` / `_light.svg` +- `card_invite_someone_privately_alpha.svg` / `_light.svg` +- `card_create_your_public_address_alpha.svg` / `_light.svg` + +Real PNGs already generated in art repo `multiplatform/resources/MR/images/`. + +## Onboarding condition (shared by Android and Desktop) + +Placed in `ConnectOnboardingView.kt` as top-level functions, accessible from both `ChatListView.kt` and `App.kt`: + +```kotlin +@Composable +fun shouldShowOnboarding(): Boolean { + val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val chats = chatModel.chats.value + return !addressCreationCardShown.value && chats.isNotEmpty() && noConversationChatsYet(chats) +} + +fun noConversationChatsYet(chats: List): Boolean = + chats.all { chat -> + when (val c = chat.chatInfo) { + is ChatInfo.Local -> true + is ChatInfo.Direct -> c.contact.chatDeleted || c.contact.isContactCard + is ChatInfo.Group -> false + is ChatInfo.ContactRequest -> true + is ChatInfo.ContactConnection -> true + is ChatInfo.InvalidJSON -> true + } + } +``` + +`shouldShowOnboarding` is `@Composable` (reads reactive state) and public — called from both `ChatListView.kt` and `App.kt`. `noConversationChatsYet` is a pure function, also public (used by auto-dismiss LaunchedEffect). + +### Auto-dismiss + +```kotlin +LaunchedEffect(chatModel.chats.value.size) { + if (!noConversationChatsYet(chatModel.chats.value)) { + appPrefs.addressCreationCardShown.set(true) + } +} +``` + +Placed in `ChatListWithLoadingScreen`. + +## Android integration + +### In `ChatListView.kt` — `ChatListWithLoadingScreen` (line 291) + +Change from: +```kotlin +private fun BoxScope.ChatListWithLoadingScreen(searchText, listState) { + if (!chatModel.desktopNoUserNoRemote) { ChatList(...) } + if (chatModel.chats.value.isEmpty() && ...) { Text("Loading/empty") } +} +``` + +To: +```kotlin +private fun BoxScope.ChatListWithLoadingScreen(searchText, listState) { + val chats = chatModel.chats.value + when { + chats.isEmpty() && !chatModel.switchingUsersAndHosts.value + && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == null -> { + Text(stringResource(MR.strings.loading_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } + shouldShowOnboarding() -> { + if (appPlatform.isAndroid) { + ConnectOnboardingView() + } + // Desktop: empty — overlay in DesktopScreen handles it + } + !chatModel.desktopNoUserNoRemote -> { + ChatList(searchText = searchText, listState) + } + } + // Auto-dismiss + LaunchedEffect(chats.size) { + if (chats.isNotEmpty() && !noConversationChatsYet(chats)) { + appPrefs.addressCreationCardShown.set(true) + } + } +} +``` + +Toolbar is a sibling in the parent `Box` (lines 150-174), stays visible. + +## Desktop integration + +### Architecture + +The overlay is the PRIMARY UI surface during onboarding. ALL interaction happens inside it — card taps, toolbar button modals, everything. `ModalManager.start` renders INTO the overlay instead of into the start panel. + +### Overlay structure in `DesktopScreen` (App.kt) + +Two visual layers in the overlay, both full-width: + +1. **Background layer:** covers center+end area only (padded left by start panel width). Opaque `MaterialTheme.colors.background`. Hides center panel content ("No selected chat") while leaving start panel fully visible underneath. + +2. **Content layer:** full window width, no background. Cards render here, centered in the full window. Clicks outside cards fall through to the start panel below. + +Both layers have top/bottom padding for toolbar height (`AppBarHeight * fontSizeSqrtMultiplier`). + +```kotlin +if (shouldShowOnboarding()) { + val oneHandUI = remember { appPrefs.oneHandUI.state } + val toolbarPadding = AppBarHeight * fontSizeSqrtMultiplier + val topPad = if (!oneHandUI.value) toolbarPadding else 0.dp + val bottomPad = if (oneHandUI.value) toolbarPadding else 0.dp + + // Background — center+end only + Box( + Modifier + .fillMaxSize() + .padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier, top = topPad, bottom = bottomPad) + .background(MaterialTheme.colors.background) + ) + + // Content — full width, cards centered + Box( + Modifier + .fillMaxSize() + .padding(top = topPad, bottom = bottomPad), + contentAlignment = Alignment.Center + ) { + ConnectOnboardingView() + } +} +``` + +Z-order: above panels and vertical divider, below `ModalManager.fullscreen`. + +### Start panel modal redirection + +During onboarding, `ModalManager.start.showInView()` renders INSIDE the overlay instead of in the start panel Box. + +In `DesktopScreen`: +```kotlin +// Start panel modals — normal location +Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) { + if (!shouldShowOnboarding()) { + ModalManager.start.showInView() + } + SwitchingUsersView() +} +``` + +Inside `ConnectOnboardingView`, on desktop: +- Watch `ModalManager.start.hasModalsOpen` +- When a start modal opens (from toolbar + button, avatar, or card tap): + 1. Cards shift RIGHT and fade to ~30% opacity (animated) + 2. `ModalManager.start.showInView()` renders on the LEFT side of the overlay with left-to-right slide animation + 3. This is the FIRST modal opening — it slides left-to-right + 4. Subsequent modals within the start modal stack open right-to-left as usual (standard `ModalManager` behavior inside the rendered area) +- When all start modals close: reverse animation — modal area slides left, cards restore position and opacity +- Clicking a faded card triggers `ModalManager.start.closeModals()` to dismiss and restore cards. This requires swapping card `onClick` handlers when `startModalsOpen` is true — each card's onClick becomes `{ ModalManager.start.closeModals() }` instead of its normal action. + +```kotlin +// Inside ConnectOnboardingView, desktop only: +val startModalsOpen = ModalManager.start.hasModalsOpen +val cardOffset by animateFloatAsState(if (startModalsOpen) 0.3f else 0f) +val cardAlpha by animateFloatAsState(if (startModalsOpen) 0.3f else 1f) +val modalSlide by animateFloatAsState(if (startModalsOpen) 0f else -1f) + +Box(Modifier.fillMaxSize()) { + // Modal area — slides from left + if (appPlatform.isDesktop) { + Box( + Modifier + .fillMaxHeight() + .widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) + .graphicsLayer { translationX = modalSlide * size.width } + ) { + ModalManager.start.showInView() + } + } + + // Cards — shift right and fade when modal open + Box( + Modifier + .fillMaxSize() + .graphicsLayer { + if (appPlatform.isDesktop) { + translationX = cardOffset * size.width + alpha = cardAlpha + } + } + ) { + HorizontalPager(...) { /* pages */ } + } +} +``` + +Card taps use `ModalManager.start.showModalCloseable` on ALL platforms — same code. On Android, the modal renders in the normal start panel modal area. On desktop during onboarding, the modal renders inside the overlay via the redirected `showInView()`. + +### Card tap actions — same on all platforms + +```kotlin +val openConnectViaLink = { + ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, ..., close = close) + } +} +``` + +No platform branching needed. `ModalManager.start` handles the modal lifecycle. Only the rendering location changes. + +### Suppress "No selected chat" in `CenterPartOfScreen` (line 373) + +```kotlin +null -> { + if (!shouldShowOnboarding() && !rememberUpdatedState(ModalManager.center.hasModalsOpen()).value) { + Box(...) { Text(stringResource(...)) } + } else if (!shouldShowOnboarding()) { + ModalManager.center.showInView() + } +} +``` + +When onboarding active: center panel shows nothing (overlay covers it visually). + +### Desktop HorizontalPager: tap only + +`userScrollEnabled = !appPlatform.isDesktop` — disables mouse swipe on desktop. + +## Revision 2 — Bug fixes from initial implementation + +### Fix 1: `fillMaxSize()` overrides `widthIn(max:)` + +In both page composables, the Column has `Modifier.fillMaxSize().widthIn(max = 500.dp)`. `fillMaxSize()` sets width to maximum, overriding the `widthIn` constraint. + +Fix: `Modifier.fillMaxHeight().widthIn(max = 500.dp)` — only fill height, let widthIn cap the width. + +### Fix 2: Modifier order — background before padding + +In the overlay Box: `.fillMaxSize().background(color).padding(...)` paints background over toolbar area. + +Fix: `.fillMaxSize().padding(...).background(color)` — padding first, background only fills content area. + +Superseded by the two-layer approach above — background layer is separate from content layer. + +### Fix 3: "You have no chats" text dropped + +The `when` block in `ChatListWithLoadingScreen` replaced two independent `if` blocks with mutually exclusive branches, dropping the "You have no chats" case. + +Fix: revert to the original `if` block structure, adding onboarding as the first check: +```kotlin +private fun BoxScope.ChatListWithLoadingScreen(searchText, listState) { + if (shouldShowOnboarding()) { + if (appPlatform.isAndroid) { + ConnectOnboardingView() + } + } else { + if (!chatModel.desktopNoUserNoRemote) { + ChatList(searchText = searchText, listState) + } + if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) { + Text(stringResource( + if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats + ), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary) + } + } + // Auto-dismiss + LaunchedEffect(chatModel.chats.value.size) { ... } +} +``` + +This preserves the original loading/empty behavior exactly. The onboarding branch is checked first — when active, it replaces everything. When inactive, original code runs unchanged. + +## ConnectOnboardingView composable + +### Structure + +```kotlin +@Composable +fun ConnectOnboardingView() { + val pagerState = rememberPagerState(initialPage = 0) { 2 } + val scope = rememberCoroutineScope() + + HorizontalPager(state = pagerState, userScrollEnabled = true) { page -> + when (page) { + 0 -> TalkToSomeonePage( + onLetSomeoneConnect = { scope.launch { pagerState.animateScrollToPage(1) } }, + onConnectViaLink = { ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = close) + }} + ) + 1 -> ConnectWithSomeonePage( + onBack = { scope.launch { pagerState.animateScrollToPage(0) } }, + onInviteSomeone = { ModalManager.start.showModalCloseable { close -> + NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) + }}, + onCreateAddress = { ModalManager.start.showModalCloseable { close -> + UserAddressView(chatModel = chatModel, shareViaProfile = false, autoCreateAddress = true, close = close) + }} + ) + } + } +} +``` + +### Page layout + +Each page uses `BoxWithConstraints` to compute card dimensions: + +```kotlin +@Composable +private fun TalkToSomeonePage(onLetSomeoneConnect: () -> Unit, onConnectViaLink: () -> Unit) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val isLandscape = maxWidth > maxHeight + val padding = 16.dp + val spacing = 16.dp + val cardWidth = if (isLandscape) (maxWidth - padding * 2 - spacing) / 2 else maxWidth - padding * 2 + val maxCardHeight = cardWidth * 0.75f + + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) { + pageHeader("Talk to someone", showBack = false, isLandscape = isLandscape) + Spacer(Modifier.weight(1f).defaultMinSize(minHeight = 16.dp)) + cardPair(isLandscape, padding, spacing, maxCardHeight) { + // card1 and card2 + } + Spacer(Modifier.weight(1f).defaultMinSize(minHeight = 16.dp)) + } + } +} +``` + +### pageHeader composable + +Shared by both pages. No duplication: + +```kotlin +@Composable +private fun pageHeader(title: String, showBack: Boolean, isLandscape: Boolean, onBack: (() -> Unit)? = null) { + val titleView = @Composable { + Text( + stringResource(title), + style = MaterialTheme.typography.h1, // largeTitle equivalent + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + if (isLandscape) { + Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + if (showBack && onBack != null) { + backButton(onBack, Modifier.align(Alignment.CenterStart)) + } + titleView() + } + } else { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + if (showBack && onBack != null) { + backButton(onBack, Modifier.align(Alignment.Start)) + } else { + Spacer(Modifier.height(AppBarHeight)) + } + titleView() + } + } +} +``` + +Back button spacer uses `AppBarHeight` (56.dp) to match the platform's back button area, not iOS's 44pt. + +### cardPair composable + +Shared layout helper, no card duplication: + +```kotlin +@Composable +private fun cardPair( + isLandscape: Boolean, + padding: Dp, + spacing: Dp, + maxCardHeight: Dp, + card1: @Composable () -> Unit, + card2: @Composable () -> Unit +) { + if (isLandscape) { + Row(Modifier.padding(horizontal = padding), horizontalArrangement = Arrangement.spacedBy(spacing)) { + Box(Modifier.weight(1f).heightIn(max = maxCardHeight)) { card1() } + Box(Modifier.weight(1f).heightIn(max = maxCardHeight)) { card2() } + } + } else { + Column(Modifier.padding(horizontal = padding), verticalArrangement = Arrangement.spacedBy(spacing)) { + Box(Modifier.fillMaxWidth().heightIn(max = maxCardHeight)) { card1() } + Box(Modifier.fillMaxWidth().heightIn(max = maxCardHeight)) { card2() } + } + } +} +``` + +### OnboardingCardView composable + +```kotlin +@Composable +fun OnboardingCardView( + imageName: ImageResource, + imageNameLight: ImageResource, + icon: ImageResource, + title: String, + subtitle: String? = null, + labelHeightRatio: Float, + onClick: () -> Unit +) +``` + +Key Compose details (from layout-compose.md checklist): +- **Image:** `contentScale = ContentScale.Fit`, `Modifier.fillMaxSize()` — scaled AND centered ✓ +- **Gradient:** `Brush.linearGradient(colorStops, start, end)` with pixel Offsets computed from image area measured size via `Modifier.onSizeChanged` or `BoxWithConstraints` +- **Gradient math:** identical to iOS — same function ported to Kotlin, same angle/scale/aspect-ratio correction +- **Corner radius:** `RoundedCornerShape(24.dp)` with `Modifier.clip()` +- **Dark/light:** `if (isInDarkTheme()) imageNameLight else imageName` for image, gradient stops selected by theme +- **Conditional assets:** `if (BuildConfigCommon.SIMPLEX_ASSETS) { Image(...) }` +- **Clickable:** `Modifier.clip(RoundedCornerShape(24.dp)).clickable(onClick = onClick)` — clip first so ripple is bounded + +#### Label stripe background + +Use the same pattern as the toolbar (from DefaultTopAppBar.kt line 43-65): +```kotlin +MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + .copy(alpha = appPrefs.inAppBarsAlpha.get()) +``` + +This exactly matches the toolbar appearance, including the user's bar transparency preference. + +#### Gradient in Compose + +```kotlin +// Compute inside BoxWithConstraints or onSizeChanged callback +val imageAreaSize = Size(width, imageHeight) +val (startUnit, endUnit) = gradientPoints( + aspectRatio = imageAreaSize.height / imageAreaSize.width, + scale = if (isInDarkTheme()) 1.5f else 1.2f +) +val brush = Brush.linearGradient( + colorStops = if (isInDarkTheme()) darkStops else lightStops, + start = Offset(startUnit.x * imageAreaSize.width, startUnit.y * imageAreaSize.height), + end = Offset(endUnit.x * imageAreaSize.width, endUnit.y * imageAreaSize.height) +) +``` + +### Card icons (Moko resource names) + +Screen 1: +- "Let someone connect to you" — `MR.images.ic_add_link` +- "Connect via link or QR code" — `MR.images.ic_qr_code` + +Screen 2: +- "Invite someone privately" — `MR.images.ic_add_link` +- "Create your public address" — `MR.images.ic_qr_code` + +### Strings + +8 new entries in `strings.xml` (`MR/base/strings.xml`): +```xml +Talk to someone +Let someone connect to you +Connect via link or QR code +Connect with someone +Invite someone privately +A link for one person to connect +Create your public address +For anyone to reach you +``` + +## Files changed + +- `ChatListView.kt` — add `shouldShowOnboarding`, `noConversationChatsYet`, modify `ChatListWithLoadingScreen`, add auto-dismiss `LaunchedEffect` +- `App.kt` — add desktop overlay in `DesktopScreen`, suppress "No selected chat" in `CenterPartOfScreen` +- `MR/base/strings.xml` — 8 new strings +- **New:** `OnboardingCards.kt` — `ConnectOnboardingView`, `OnboardingCardView`, `TalkToSomeonePage`, `ConnectWithSomeonePage`, `pageHeader`, `cardPair`, `shouldShowOnboarding`, `noConversationChatsYet`, gradient math +- **New:** 8 stub SVGs in `assets/default/MR/images/` diff --git a/plans/2026-04-06-onboarding-cards-ios.md b/plans/2026-04-06-onboarding-cards-ios.md new file mode 100644 index 0000000000..f20936ea05 --- /dev/null +++ b/plans/2026-04-06-onboarding-cards-ios.md @@ -0,0 +1,501 @@ +# Onboarding Cards — Layout Specification & iOS Implementation Plan + +## Layout Specification (cross-platform) + +This section is the authoritative reference for implementing on any platform. + +### Overall structure + +Two screens, each with a title and two tappable cards. Screens are connected by a horizontal paging transition (swipe or tap). Screen 1 has no back button; Screen 2 has a back button. Deeper views (1-time link, connect via link, SimpleX address) open as modal sheets from card taps, NOT as navigation pushes. + +The chat list toolbar (top or bottom depending on platform/settings) remains visible on both screens — the onboarding content occupies only the chat list content area. + +### Page header + +Each page has a header area containing: +- **Back button area:** fixed height 44pt. Screen 1: empty space. Screen 2: "< Back" button left-aligned. +- **Title:** centered, largeTitle font, bold, single line, shrinks to 75% minimum scale factor. +- Screen 1 title: "Talk to someone" +- Screen 2 title: "Connect with someone" + +**Portrait:** back button area and title are two separate rows (VStack). +**Landscape:** back button and title share one row (ZStack — back button leading, title centered). No separate back button row — saves vertical space. + +Padding: 16pt horizontal on the header container. Back button has no padding of its own. + +### Card layout + +**Portrait:** two cards stacked vertically (VStack, spacing 16pt). +**Landscape:** two cards side-by-side (HStack, spacing 16pt). + +Card horizontal padding: 16pt each side. + +Cards are vertically centered in the remaining space below the header. Equal space above and below the card group (Spacer with minLength 16pt on both sides). + +### Card max height + +Max total card height = card width × 0.75. + +In portrait: card width = screen width − 32pt (16pt padding each side). +In landscape: card width = (screen width − 32pt − 16pt spacing) / 2. + +Card height can be less than max on small screens. Height never exceeds max. + +### Card component + +Each card is a rounded rectangle (corner radius 24pt) containing: + +1. **Image area** (top) — gradient background + alpha-channel illustration overlay +2. **Label stripe** (bottom) — toolbar material background, fixed proportional height + +#### Image area + +- Gradient fills only the image area, NOT the label stripe +- Illustration: `.resizable().scaledToFit()`, fills available space, clipped to image area + +#### Gradient + +Stops (light mode): +- `#d2e8ff` (rgb 0.824, 0.910, 1.0) at 0% +- `#cce9ff` (rgb 0.800, 0.914, 1.0) at 50% +- `#dfffff` (rgb 0.875, 1.0, 1.0) at 90% +- `#fffcea` (rgb 1.0, 0.988, 0.918) at 100% + +Stops (dark mode): +- `#040a24` (rgb 0.016, 0.039, 0.141) at 40% +- `#3854ab` (rgb 0.220, 0.329, 0.671) at 72% +- `#a8edf3` (rgb 0.659, 0.929, 0.953) at 90% +- `#fff6e0` (rgb 1.0, 0.965, 0.878) at 100% + +Angle: 80° counter-clockwise from horizontal (almost vertical, slight rightward lean at top). + +Gradient scale (center-anchored): 1.2× in light mode, 1.5× in dark mode. This pushes start/end points further from center, reducing colored corner area. + +**Gradient endpoint calculation** (accounts for variable card aspect ratio): + +The gradient must maintain a constant 80° visual angle regardless of card proportions. Given the IMAGE AREA aspect ratio `r = imageHeight / width`: + +``` +θ = 80° (in radians: 80 × π / 180) +dx = cos(θ) +dy = −sin(θ) / r + +Project four corners (±0.5, ±0.5) onto direction (dx, dy): + projections = [−0.5·dx + (−0.5)·dy, 0.5·dx + (−0.5)·dy, −0.5·dx + 0.5·dy, 0.5·dx + 0.5·dy] + tMin = min(projections), tMax = max(projections) + dLenSq = dx² + dy² + +Base endpoints: + startX = 0.5 + tMin·dx/dLenSq, startY = 0.5 + tMin·dy/dLenSq + endX = 0.5 + tMax·dx/dLenSq, endY = 0.5 + tMax·dy/dLenSq + +Apply scale S (1.2 or 1.5) center-anchored: + finalStart = (0.5 + (startX−0.5)·S, 0.5 + (startY−0.5)·S) + finalEnd = (0.5 + (endX−0.5)·S, 0.5 + (endY−0.5)·S) +``` + +Important: aspect ratio uses IMAGE AREA height (card height minus label stripe), not total card height. + +#### Label stripe + +Height relative to card width: +- Single-line labels (Screen 1): 0.132 × card width +- Two-line labels (Screen 2): 0.195 × card width + +Background: platform toolbar material (matches the app toolbar appearance). On iOS: `Material` from `ToolbarMaterial` user setting. On Android: equivalent translucent material. + +Content layout: centered horizontally. +- Icon: 24pt, theme primary/accent color +- Title: body font (17pt), medium weight, theme foreground color, single line, shrinks to 75% +- Subtitle (Screen 2 only): footnote (13pt), theme foreground at 70% opacity + +Label stripe sits below the image area — gradient does NOT extend under it. + +### Card images + +8 alpha-channel PNGs (4 illustrations × light/dark variants). + +Screen 1: +- `card-let-someone-connect-to-you-alpha` / `-light` +- `card-connect-via-link-alpha` / `-light` + +Screen 2: +- `card-invite-someone-privately-alpha` / `-light` +- `card-create-your-public-address-alpha` / `-light` + +Light/dark selection: use base name on light backgrounds, `-light` suffix on dark backgrounds. + +Gated behind build flag (`#if SIMPLEX_ASSETS` on iOS, `BuildConfigCommon.SIMPLEX_ASSETS` on Android). Without assets: gradient-only cards with label stripe, still functional. + +### Card icons (SF Symbols / Material equivalents) + +Screen 1: +- "Let someone connect to you" — `link.badge.plus` +- "Connect via link or QR code" — `qrcode.viewfinder` + +Screen 2: +- "Invite someone privately" — `link.badge.plus` +- "Create your public address" — `qrcode` + +### Card actions + +Screen 1: +- Left card ("Let someone connect to you") → paging transition to Screen 2 +- Right card ("Connect via link or QR code") → modal sheet with ConnectView + +Screen 2: +- Left card ("Invite someone privately") → modal sheet with InviteView (1-time link) +- Right card ("Create your public address") → modal sheet with UserAddressView (auto-create) + +### Onboarding visibility + +Controlled by existing user default `addressCreationCardShown` (key: `"AddressCreationCardShown"`). + +Show onboarding when: +- `addressCreationCardShown == false` +- Chat list is not empty (chats have loaded) +- All chats are "ignorable" (note folders, deleted contacts, contact cards, pending connections/requests, invalid JSON) +- Any group = real conversation → onboarding hidden + +Auto-dismiss: when first real conversation appears, set `addressCreationCardShown = true` permanently. Observed via chat list count changes. + +### Strings (8) + +- "Talk to someone" +- "Let someone connect to you" +- "Connect via link or QR code" +- "Connect with someone" +- "Invite someone privately" +- "A link for one person to connect" +- "Create your public address" +- "For anyone to reach you" + +--- + +## Scope + +Screens 1 and 2 only — two card selection screens with slide navigation between them. No standalone onboarding variants of existing views. No banner. Those are separate future work. + +## New file + +`Shared/Views/NewChat/OnboardingCards.swift` — all new code in one file. + +## What it contains + +### `OnboardingCardView` — reusable card component + +```swift +struct OnboardingCardView: View { + @Environment(\.colorScheme) var colorScheme + let imageName: String // base asset name (without -light suffix) + let icon: String // SF Symbol name + let title: LocalizedStringKey + let subtitle: LocalizedStringKey? // nil for screen 1 cards + let action: () -> Void +} +``` + +Image selection follows the project convention: +- `colorScheme == .light` → `imageName` (base name, dark-colored image for light backgrounds) +- `colorScheme == .dark` → `"\(imageName)-light"` (light-colored image for dark backgrounds) + +Note: this only works when the base name does NOT already contain `-light`. The card image base names are like `card-let-someone-connect-to-you-alpha` — the `-alpha` suffix distinguishes them, and appending `-light` gives `card-let-someone-connect-to-you-alpha-light`. Correct. + +Structure (inside → out): +1. `Button(action:)` wrapping the entire card for tap handling, with `.buttonStyle(.plain)` to prevent default blue tint +2. Clipped to `RoundedRectangle(cornerRadius: 18)` +3. Inside, `ZStack(alignment: .bottom)`: + - `LinearGradient` filling the card shape + - `VStack(spacing: 0)`: + - `#if SIMPLEX_ASSETS` block: `Image` with `.resizable().scaledToFit().frame(maxWidth: .infinity, maxHeight: .infinity)` — takes all space above label. Image uses `.clipped()` to prevent overflow into label area. + - `#else` block: `Spacer()` — gradient-only card, label still functional. + - Label area with fixed height: `HStack(spacing: 8)` with `Image(systemName: icon)` (20pt) + `VStack(alignment: .leading, spacing: 2)` containing title + optional subtitle. Padded `(.horizontal, 16)` and `(.vertical, 12)`. + +Gradient stops (using `Color(red:green:blue:)` with values 0-1, no hex extension exists in the project): +- Light: + - `Color(red: 0.824, green: 0.910, blue: 1.0)` at 0.0 (#d2e8ff) + - `Color(red: 0.800, green: 0.914, blue: 1.0)` at 0.5 (#cce9ff) + - `Color(red: 0.875, green: 1.0, blue: 1.0)` at 0.9 (#dfffff) + - `Color(red: 1.0, green: 0.988, blue: 0.918)` at 1.0 (#fffcea) +- Dark: + - `Color(red: 0.016, green: 0.039, blue: 0.141)` at 0.4 (#040a24) + - `Color(red: 0.220, green: 0.329, blue: 0.671)` at 0.72 (#3854ab) + - `Color(red: 0.659, green: 0.929, blue: 0.953)` at 0.9 (#a8edf3) + - `Color(red: 1.0, green: 0.965, blue: 0.878)` at 1.0 (#fff6e0) +- Angle: 80° from vertical = 10° from horizontal. `LinearGradient(stops:..., startPoint: .init(x: 0.0, y: 0.6), endPoint: .init(x: 1.0, y: 0.4))`. Must verify visually — the exact start/end points for 80° depend on the view's aspect ratio. May need adjustment. + +Define the gradient stops as static properties on `OnboardingCardView` to avoid recomputing them on every recomposition. + +Label text styles: +- Title: `.body` weight `.semibold`, color `Color.white` in dark mode, `Color.primary` in light mode (from design: dark text on light gradient, light text on dark gradient). +- Subtitle: `.footnote`, color `.secondary` (adapts to theme). +- Icon: same color as title. + +### `TalkToSomeoneView` — Screen 1 + +```swift +struct TalkToSomeoneView: View { + @EnvironmentObject var theme: AppTheme + @State private var showConnectWithSomeone = false + @State private var showConnectViaLink = false +``` + +Body — NOT scrollable, fills available space: + +```swift +var body: some View { + VStack(spacing: 16) { + Text("Talk to someone") + .font(.largeTitle) + .fontWeight(.bold) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + + OnboardingCardView( + imageName: "card-let-someone-connect-to-you-alpha", + icon: "link", + title: "Let someone connect to you", + subtitle: nil, + action: { showConnectWithSomeone = true } + ) + .frame(maxHeight: .infinity) + .padding(.horizontal, 16) + + OnboardingCardView( + imageName: "card-connect-via-link-alpha", + icon: "qrcode", + title: "Connect via link or QR code", + subtitle: nil, + action: { showConnectViaLink = true } + ) + .frame(maxHeight: .infinity) + .padding(.horizontal, 16) + } + .padding(.vertical, 16) + .background( + NavigationLink(isActive: $showConnectWithSomeone) { + ConnectWithSomeoneView() + } label: { EmptyView() } + ) + .background( + NavigationLink(isActive: $showConnectViaLink) { + NewChatView(selection: .connect, showQRCodeScanner: true) + .navigationBarTitleDisplayMode(.inline) + } label: { EmptyView() } + ) +} +``` + +Key layout decisions: +- `.frame(maxHeight: .infinity)` on each card makes them share remaining vertical space equally after the title takes its natural height. +- `.padding(.vertical, 16)` on the VStack adds 16pt above the title and 16pt below the second card (VStack `spacing` only applies between children, not before first or after last). +- Hidden `NavigationLink(isActive:)` in `.background()` — drives navigation without affecting layout. This is the deprecated iOS 15 API but it works on iOS 16+ inside `NavigationStack` and is used throughout the existing codebase (e.g., `NewChatMenuButton.swift` lines 100-110). + +**`oneHandUI` inversion handling:** `TalkToSomeoneView` replaces `chatList` content. `chatListView` applies `.scaleEffect(x: 1, y: oneHandUI ? -1 : 1)` to the root page. The onboarding view gets inverted. It must counter-invert with `.scaleEffect(x: 1, y: oneHandUI ? -1 : 1)`. This is applied in `ChatListView.chatList`, NOT inside `TalkToSomeoneView` — the caller is responsible. When NavigationLink pushes Screen 2 or further views, those are new navigation pages outside the root page's scale effect, so they render normally. + +### `ConnectWithSomeoneView` — Screen 2 + +```swift +struct ConnectWithSomeoneView: View { + @EnvironmentObject var theme: AppTheme + @State private var showInviteSomeone = false + @State private var showCreateAddress = false +``` + +Same VStack layout as Screen 1, with these differences: +- Title: "Connect with someone" +- Card 1: imageName `"card-invite-someone-privately-alpha"`, icon `"link"`, title "Invite someone privately", subtitle "A link for one person to connect" → sets `showInviteSomeone = true` +- Card 2: imageName `"card-create-your-public-address-alpha"`, icon `"qrcode"`, title "Create your public address", subtitle "For anyone to reach you" → sets `showCreateAddress = true` + +Navigation destinations (existing views, unmodified — onboarding variants are future work): +- `showInviteSomeone` → `NewChatView(selection: .invite)` — tabbed view, 1-time link tab pre-selected. Has tabs (not ideal) but functional. +- `showCreateAddress` → `UserAddressView(shareViaProfile: false, autoCreate: true)` — auto-creates address on appear. + +Both wrapped with `.navigationBarTitleDisplayMode(.inline)`. + +Navigation bar back button shows automatically (pushed via NavigationLink within the stack). + +## Integration into ChatListView + +### In `chatList` property (line 351 of ChatListView.swift) + +Current code: +```swift +private var chatList: some View { + let cs = filteredChats() + return ZStack { + ScrollViewReader { scrollProxy in + List { ... } + } + } +} +``` + +Changed to: +```swift +@ViewBuilder +private var chatList: some View { + if shouldShowOnboarding { + TalkToSomeoneView() + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + } else { + let cs = filteredChats() + ZStack { + ScrollViewReader { scrollProxy in + List { ... } + } + } + } +} +``` + +Requires `@ViewBuilder` because `if/else` returns different view types. + +**`oneHandUI` inversion:** The `.scaleEffect(y: -1)` is applied by `chatListView` to the root page of the navigation stack. `TalkToSomeoneView` counter-inverts at the call site. When `NavigationLink` pushes Screen 2 or further, those are new navigation pages NOT affected by the root page's `.scaleEffect`. Only the root content needs the flip. + +### `shouldShowOnboarding` and `noConversationChatsYet` + +```swift +private var shouldShowOnboarding: Bool { + !addressCreationCardShown && noConversationChatsYet +} + +private var noConversationChatsYet: Bool { + chatModel.chats.allSatisfy { chat in + switch chat.chatInfo { + case .local: return true + case let .direct(contact): return contact.chatDeleted || contact.isContactCard + case let .group(groupInfo, _): return groupInfo.chatDeleted + case let .contactRequest(req): return req.chatDeleted + case let .contactConnection(conn): return conn.chatDeleted + case .invalidJSON: return true + } + } +} +``` + +Both are computed properties on `ChatListView`. `noConversationChatsYet` reads `chatModel.chats` which is `@Published` on `ChatModel` (`@EnvironmentObject`). SwiftUI re-evaluates the body when it changes, so `shouldShowOnboarding` is reactive. + +Note: `chatModel.chats` may be empty during initial load (before `APIGetChats` completes). `allSatisfy` on an empty array returns `true`. Combined with `!addressCreationCardShown`, this means the onboarding flashes briefly on app launch for users who have conversations but `chats` hasn't loaded yet. Mitigation: also check `chatModel.chats.isEmpty` and show a loading indicator instead: + +```swift +private var shouldShowOnboarding: Bool { + !addressCreationCardShown && !chatModel.chats.isEmpty && noConversationChatsYet +} +``` + +When `chats` is empty (loading), neither onboarding nor chat list shows — the existing loading state (if any) handles it. + +### Auto-dismiss + +`addressCreationCardShown` must be set to `true` when the first real conversation appears, so the onboarding never returns. + +```swift +.onChange(of: chatModel.chats.count) { _ in + if !noConversationChatsYet && !addressCreationCardShown { + addressCreationCardShown = true + } +} +``` + +Placed on `chatList` view. Observes `.count` as a proxy for chat list changes. When count changes and `noConversationChatsYet` is false, the user default is set permanently. This covers: receiving a contact request, establishing a connection, creating a group, etc. + +Edge case: chat count can change without affecting `noConversationChatsYet` (e.g., adding a second note folder). The check `!noConversationChatsYet` prevents unnecessary writes — only sets the default when there's actually a real conversation. + +## User default + +Existing `@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false` at ChatListView line 165. Constant defined in `SettingsView.swift` line 55 as `let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown"`. Also referenced in `AddressCreationCard.swift` line 17 and in `SettingsView.swift` defaults reset (line 114, 144). + +No new user default needed. + +## String localization + +8 new strings for `Localizable.strings` (en). Use `NSLocalizedString` or `LocalizedStringKey` inline — project uses both patterns. + +- "Talk to someone" +- "Let someone connect to you" +- "Connect via link or QR code" +- "Connect with someone" +- "Invite someone privately" +- "A link for one person to connect" +- "Create your public address" +- "For anyone to reach you" + +## Assets + +8 card images in art repo (4 base + 4 light variants). Run `resize.sh` then `copy-assets.sh` to populate `SimpleXAssets.xcassets`. Gated with `#if SIMPLEX_ASSETS`. Without assets: gradient-only cards with labels, still tappable and functional. + +Image base names for the `imageName` parameter: +- Screen 1: `"card-let-someone-connect-to-you-alpha"`, `"card-connect-via-link-alpha"` +- Screen 2: `"card-invite-someone-privately-alpha"`, `"card-create-your-public-address-alpha"` + +The `-light` suffix is appended automatically by `OnboardingCardView` when `colorScheme == .dark`. + +## Files changed + +- `Shared/Views/ChatList/ChatListView.swift` — add `shouldShowOnboarding`, `noConversationChatsYet`, add `@ViewBuilder` to `chatList`, branch to `TalkToSomeoneView`, add `.onChange` for auto-dismiss +- **New:** `Shared/Views/NewChat/OnboardingCards.swift` — `OnboardingCardView`, `TalkToSomeoneView`, `ConnectWithSomeoneView` + +No modifications to NewChatView, UserAddressView, or ConnectView in this phase. + +## Revision 1 — corrections from design review + +### Navigation scope (critical) +Both screens must keep the bottom/top toolbar visible. The onboarding NavigationView is SCOPED to just the card area — it does NOT replace the full chatListView. In `chatList`, wrap `TalkToSomeoneView()` in its own `NavigationView { }.navigationViewStyle(.stack)`. The toolbar from `chatListView.withToolbar()` stays outside and visible on both screens. + +Screen 1 → Screen 2: real NavigationLink push within the scoped NavigationView. +Screen 2 → deeper views: also NavigationLink pushes within same scoped NavigationView. + +### Screen 1 — reserve nav bar space +Screen 2 has a back button (navigation bar). Screen 1 must reserve the same height to prevent content shift on slide. Set `.navigationTitle("")` with `.navigationBarTitleDisplayMode(.inline)` on Screen 1's root — shows an empty inline nav bar matching Screen 2's bar height. + +### Gradient direction fix +Current gradient is nearly horizontal — wrong. Correct angle is 80° CCW from horizontal (almost vertical, slight rightward lean). + +Formula for full-coverage gradient at angle θ: +``` +startPoint = (0.5 - 0.5·cos(θ), 0.5 + 0.5·sin(θ)) +endPoint = (0.5 + 0.5·cos(θ), 0.5 - 0.5·sin(θ)) +``` + +For θ = 80°: `startPoint: .init(x: 0.413, y: 0.992), endPoint: .init(x: 0.587, y: 0.008)` + +### Corner radius +Change from 18 to 24. + +### Label stripe background +The label area has a distinct semi-transparent background strip at the bottom of the card. Add to `labelRow`: +- Light mode: `Color.white.opacity(0.5)` +- Dark mode: `Color.black.opacity(0.3)` +Exact opacity values need visual tuning. + +### Card max height ratio +Cards have a max total height/width ratio of 0.75. On tall screens, cards are capped at this ratio with extra space distributed equally above and below. On short screens, cards shrink — ratio goes below 0.75, label stripe stays fixed height, only image area shrinks. + +Implementation: use `GeometryReader` to get available width, compute `maxCardHeight = cardWidth * 0.75`, apply `.frame(maxHeight: maxCardHeight)` on each card. The VStack centers vertically in the GeometryReader — equal space above and below on tall screens. + +### Title alignment +Change from `.leading` to `.center` — design shows centered titles on both screens. + +### Subtitle color in dark mode +Change `.foregroundColor(.secondary)` to `.foregroundColor(colorScheme == .dark ? .white.opacity(0.7) : .secondary)` — standard `.secondary` is too gray on the dark gradient. + +### Label stripe height proportions +The label stripe has fixed proportional heights relative to card width: +- Screen 1 (single-line labels): 0.132 × card width +- Screen 2 (two-line labels): 0.195 × card width + +These are achieved via fixed padding on the label row. The image area is the remainder of the card height. When cards shrink on short screens, only the image area shrinks — the label stripe stays at its proportional height. + +### Spacing between title and cards, between cards, and below cards +The gaps above first card and below second card should be EQUAL and LARGER than the gap between the two cards. The inter-card gap is the VStack spacing (~16pt). The outer gaps are larger — achieved by the GeometryReader centering the VStack vertically, which distributes extra space equally above and below. + +### ThemedBackground on TalkToSomeoneView +`TalkToSomeoneView` needs `.modifier(ThemedBackground())` — it replaces `chatList` content and needs its own background. Currently missing. + +### `oneHandUI` inversion on Screen 2 +The scoped `NavigationView` sits inside `chatList` which is visually inverted by `chatListView`'s `.scaleEffect(y: -1)`. This inversion applies to the NavigationView's rendered frame — ALL pages inside it (both Screen 1 and Screen 2) are inverted. `TalkToSomeoneView` counter-inverts at the call site. `ConnectWithSomeoneView` (pushed within the NavigationView) also needs counter-inversion. Pass `oneHandUI` as a binding or read from `@AppStorage(GROUP_DEFAULT_ONE_HAND_UI)` directly inside `ConnectWithSomeoneView`, and apply `.scaleEffect(x: 1, y: oneHandUI ? -1 : 1)` on its root VStack. Same for any deeper pushed views — but those are existing views not modified in this phase, so their inversion behavior needs testing. + +### Plan cleanup note +The original sections above contain outdated code snippets (wrong gradient, wrong corner radius, wrong switch cases, wrong alignment). The Revision 1 sections are authoritative. When implementing, follow Revision 1 values; treat original sections as structural context only. + diff --git a/scripts/android/copy-assets.sh b/scripts/android/copy-assets.sh new file mode 100755 index 0000000000..c76383faae --- /dev/null +++ b/scripts/android/copy-assets.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +# Copies generated multiplatform assets into the SimpleX assets directory. +# Called by Gradle build when simplex.assets.dir property is set. +# +# Usage: copy-assets.sh + +SRC_DIR="$1/multiplatform/resources/MR/images" +DEST_DIR="$2/MR/images" + +if [ ! -d "$SRC_DIR" ]; then + echo "Error: source assets not found: $SRC_DIR (run resize.sh first)" >&2 + exit 1 +fi + +rm -rf "$DEST_DIR" +mkdir -p "$DEST_DIR" + +cp "$SRC_DIR"/* "$DEST_DIR/" +echo "Copied multiplatform assets to $DEST_DIR" diff --git a/scripts/ios/copy-assets.sh b/scripts/ios/copy-assets.sh new file mode 100755 index 0000000000..f29ebd2464 --- /dev/null +++ b/scripts/ios/copy-assets.sh @@ -0,0 +1,50 @@ +#!/bin/sh +set -eu + +# Copies generated iOS assets into SimpleXAssets.xcassets. +# Intended to run as an Xcode Run Script build phase. +# Skips silently if SIMPLEX_ASSETS is not in SWIFT_ACTIVE_COMPILATION_CONDITIONS +# or if the source directory is not found. +# +# The source path is resolved in order: +# 1. Command-line argument +# 2. SIMPLEX_ASSETS_DIR build setting (set in Local.xcconfig) +# 3. No default — skips if neither is set +# +# Manual usage: ./scripts/copy-assets.sh path/to/assets + +# Skip if SIMPLEX_ASSETS flag is not set (unless run manually outside Xcode) +if [ -n "${SWIFT_ACTIVE_COMPILATION_CONDITIONS:-}" ]; then + case " $SWIFT_ACTIVE_COMPILATION_CONDITIONS " in + *" SIMPLEX_ASSETS "*) ;; + *) exit 0 ;; + esac +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +IOS_DIR="$SCRIPT_DIR/../../apps/ios/Shared/SimpleXAssets.xcassets" + +ASSETS_ROOT="${1:-${SIMPLEX_ASSETS_DIR:-}}" +if [ -z "$ASSETS_ROOT" ]; then + echo "warning: SIMPLEX_ASSETS_DIR not set and no path argument provided" >&2 + exit 0 +fi + +SRC_DIR="$ASSETS_ROOT/ios/Assets.xcassets" + +if [ ! -d "$SRC_DIR" ]; then + echo "warning: source assets not found: $SRC_DIR (run resize.sh first)" >&2 + exit 0 +fi + +# Remove old imagesets but keep root Contents.json +find "$IOS_DIR" -name "*.imageset" -type d -exec rm -rf {} + 2>/dev/null || true + +# Copy imagesets +for imageset in "$SRC_DIR"/*.imageset; do + [ -d "$imageset" ] || continue + cp -r "$imageset" "$IOS_DIR/" + echo "Copied $(basename "$imageset")" +done + +echo "Done. Assets copied to $IOS_DIR"