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>
This commit is contained in:
Evgeny
2026-04-21 17:41:52 +01:00
committed by GitHub
parent 6f86c5af2e
commit 035a2f954c
62 changed files with 2858 additions and 407 deletions
+4
View File
@@ -69,3 +69,7 @@ Libraries/
Shared/MyPlayground.playground/*
testpush.sh
# Local build config and generated assets
Local.xcconfig
Shared/SimpleXAssets.xcassets/*.imageset
+2
View File
@@ -0,0 +1,2 @@
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
#include? "Local.xcconfig"
+21
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
#include? "Local.xcconfig"
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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)
@@ -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()
@@ -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")
}
}
+99 -26
View File
@@ -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)
@@ -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<EnvironmentValues, RefreshAction?>, nil)
}
.sheet(isPresented: $showInviteSomeone) {
NavigationView {
NewChatView(selection: .invite, onboarding: true)
.modifier(ThemedBackground(grouped: true))
}
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
.sheet(isPresented: $showCreateAddress) {
NavigationView {
UserAddressView(autoCreate: true, onboarding: true)
.modifier(ThemedBackground(grouped: true))
}
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
}
}
@ViewBuilder
private func cardPair<C1: View, C2: View>(
_ 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)
}
}
}
@@ -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()
}
@@ -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)
}
}
@@ -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"),
+43 -5
View File
@@ -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 = "<group>"; };
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvertedForegroundStyle.swift; sourceTree = "<group>"; };
CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = "<group>"; };
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = "<group>"; };
@@ -621,6 +623,10 @@
E559A0A02E3F77EE00B26F74 /* CommandsMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandsMenuView.swift; sourceTree = "<group>"; };
E5AEC0AA2F91A6EA00270665 /* CIChatLinkHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatLinkHeader.swift; sourceTree = "<group>"; };
E5AEC0AE2F91A73500270665 /* ComposeChatLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeChatLinkView.swift; sourceTree = "<group>"; };
E5C0BBE72F82B45500EA7527 /* SimpleXAssets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = SimpleXAssets.xcassets; sourceTree = "<group>"; };
E5C0BBFD2F82BBC000EA7527 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
E5C0BBFE2F82BBC900EA7527 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
E5DBF1922F88169800E1D7FD /* ConnectBannerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectBannerCard.swift; sourceTree = "<group>"; };
E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
@@ -675,6 +681,7 @@
E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAPITypes.swift; sourceTree = "<group>"; };
E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEAPITypes.swift; sourceTree = "<group>"; };
E5E418002F83D2CA00252B9E /* OnboardingCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingCards.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -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;
@@ -1,5 +1,5 @@
{
"originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1",
"originHash" : "60aeecb7917535a5e44ade0dbb5411ab112a959283e565a04c212c8af4e7dee9",
"pins" : [
{
"identity" : "codescanner",
+4 -1
View File
@@ -16,4 +16,7 @@ android/build
android/release
common/build
desktop/build
release
release
# Generated SimpleX assets
common/src/commonMain/resources/assets/simplex/
+3
View File
@@ -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
@@ -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<Exec>("copySimplexAssets") {
commandLine(
"${rootProject.rootDir}/../../scripts/android/copy-assets.sh",
resolvedAssetsDir,
simplexAssetsLocal.absolutePath
)
}
} else {
tasks.register<Delete>("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 {
@@ -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()
@@ -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()
}
}
@@ -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
@@ -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<TextFieldValue>, 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<AnimatedViewState>
}
},
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<TextFieldValue>, 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()
@@ -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) {
@@ -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(
@@ -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
)
@@ -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<PendingContactConnection?> = 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<PendingContactConnection?>, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState<Boolean>) {
private fun PrepareAndInviteView(rhId: Long?, contactConnection: MutableState<PendingContactConnection?>, connLinkInvitation: CreatedConnLink, creatingConnReq: MutableState<Boolean>, 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<PendingContactConnection?>) {
private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contactConnection: MutableState<PendingContactConnection?>, 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<Boolean>, pastedLink: MutableState<String>, close: () -> Unit) {
private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState<Boolean>, pastedLink: MutableState<String>, 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<String>, 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()
@@ -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<Chat>): 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()
}
}
}
@@ -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<Boolean>,
onboarding: Boolean = false,
createAddress: () -> Unit,
showAddShortLinkAlert: ((() -> Unit)?) -> Unit,
learnMore: () -> Unit,
@@ -259,68 +279,100 @@ private fun UserAddressLayout(
saveAddressSettings: (AddressSettingsState, MutableState<AddressSettingsState>) -> 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))
}
}
}
}
@@ -465,6 +465,15 @@
<string name="tap_to_start_new_chat">Tap to start a new chat</string>
<string name="chat_with_developers">Chat with the developers</string>
<string name="you_have_no_chats">You have no chats</string>
<string name="talk_to_someone">Talk to someone</string>
<string name="let_someone_connect_to_you">Let someone connect to you</string>
<string name="connect_via_link_or_qr_code">Connect via link or QR code</string>
<string name="connect_with_someone">Connect with someone</string>
<string name="invite_someone_privately">Invite someone privately</string>
<string name="a_link_for_one_person">A link for one person to connect</string>
<string name="create_your_public_address">Create your public address</string>
<string name="your_public_address">Your public address</string>
<string name="for_anyone_to_reach_you">For anyone to reach you</string>
<string name="loading_chats">Loading chats…</string>
<string name="no_filtered_chats">No filtered chats</string>
<string name="no_chats_in_list">No chats in list %s.</string>
@@ -904,7 +913,7 @@
<string name="new_chat">New chat</string>
<string name="new_message">New message</string>
<string name="add_contact_tab">Add contact</string>
<string name="scan_paste_link">Scan / Paste link</string>
<string name="scan_paste_link">Paste link / Scan</string>
<string name="paste_link">Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
@@ -1132,12 +1141,12 @@
<string name="all_your_contacts_will_remain_connected">All your contacts will remain connected.</string>
<string name="all_your_contacts_will_remain_connected_update_sent">All your contacts will remain connected. Profile update will be sent to your contacts.</string>
<string name="share_link">Share link</string>
<string name="add_address_to_your_profile">Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts.</string>
<string name="add_address_to_your_profile">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.</string>
<string name="create_address_and_let_people_connect">Create an address to let people connect with you.</string>
<string name="create_simplex_address">Create SimpleX address</string>
<string name="share_with_contacts">Share with contacts</string>
<string name="share_address_with_contacts_question">Share address with contacts?</string>
<string name="profile_update_will_be_sent_to_contacts">Profile update will be sent to your contacts.</string>
<string name="share_with_contacts">Share with SimpleX contacts</string>
<string name="share_address_with_contacts_question">Share address with SimpleX contacts?</string>
<string name="profile_update_will_be_sent_to_contacts">Profile update will be sent to your SimpleX contacts.</string>
<string name="stop_sharing_address">Stop sharing address?</string>
<string name="stop_sharing">Stop sharing</string>
<string name="auto_accept_contact">Auto-accept</string>
@@ -1154,6 +1163,11 @@
<string name="or_to_share_privately">Or to share privately</string>
<string name="simplex_address_or_1_time_link">SimpleX address or 1-time link?</string>
<string name="create_1_time_link">Create 1-time link</string>
<string name="new_1_time_link">New 1-time link</string>
<string name="onboarding_send_1_time_link">Send the link via any messenger - it\'s secure. Ask to paste into SimpleX.</string>
<string name="onboarding_or_show_qr_code">Or show QR in person or via video call.</string>
<string name="onboarding_post_address">Use this address in your social media profile, website, or email signature.</string>
<string name="onboarding_or_use_qr_code">Or use this QR - print or show online.</string>
<string name="address_settings">Address settings</string>
<string name="business_address">Business address</string>
<string name="add_your_team_members_to_conversations">Add your team members to the conversations.</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#000000"><path d="M85-704.5V-875h170.5v57.5h-113v113H85ZM85-85v-170.5h57.5v113h113V-85H85Zm619.5 0v-57.5h113v-113H875V-85H704.5Zm113-619.5v-113h-113V-875H875v170.5h-57.5ZM705-254h61.5v61.5H705V-254Zm0-123h61.5v61.5H705V-377Zm-61.5 61.5H705v61.5h-61.5v-61.5ZM582-254h61.5v61.5H582V-254Zm-61.5-61.5H582v61.5h-61.5v-61.5Zm123-123H705v61.5h-61.5v-61.5ZM582-377h61.5v61.5H582V-377Zm-61.5-61.5H582v61.5h-61.5v-61.5Zm246-329v246h-246v-246h246Zm-328 329v246h-246v-246h246Zm0-329v246h-246v-246h246Zm-48 526.5v-149.5H241V-241h149.5Zm0-328.5V-719H241v149.5h149.5Zm327.5 0V-719H568.5v149.5H718Z"/></svg>

After

Width:  |  Height:  |  Size: 689 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path d="M0,0h24v24H0z" fill="#00000000"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<rect width="24" height="24" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -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,
@@ -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
@@ -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<Chat>): 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
<string name="talk_to_someone">Talk to someone</string>
<string name="let_someone_connect_to_you">Let someone connect to you</string>
<string name="connect_via_link_or_qr">Connect via link or QR code</string>
<string name="connect_with_someone">Connect with someone</string>
<string name="invite_someone_privately">Invite someone privately</string>
<string name="a_link_for_one_person">A link for one person to connect</string>
<string name="create_your_public_address">Create your public address</string>
<string name="for_anyone_to_reach_you">For anyone to reach you</string>
```
## 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/`
+501
View File
@@ -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 + (startX0.5)·S, 0.5 + (startY0.5)·S)
finalEnd = (0.5 + (endX0.5)·S, 0.5 + (endY0.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.
+21
View File
@@ -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 <source-dir> <dest-dir>
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"
+50
View File
@@ -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"