From cd670548b9e89e5fad276b40a735c1e728730e12 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Fri, 14 Aug 2026 07:36:02 +0000 Subject: [PATCH] ui: badges WIP views (#7321) --- .../Views/Badges/BadgeUserPreview.swift | 39 ++++ .../Views/Badges/BadgesHowItWorksView.swift | 46 +++++ .../Shared/Views/Badges/BadgesPayView.swift | 146 ++++++++++++++ .../Views/Badges/BadgesRedeemCodeView.swift | 36 ++++ .../Badges/BadgesSupportSimplexView.swift | 165 +++++++++++++++ .../Views/Badges/BadgesYourLevelView.swift | 188 ++++++++++++++++++ .../Views/Badges/SupportSimpleXBanner.swift | 129 ++++++++++++ .../Shared/Views/ChatList/ChatListView.swift | 19 ++ apps/ios/Shared/Views/Helpers/NameBadge.swift | 2 +- .../Shared/Views/Onboarding/HowItWorks.swift | 2 + .../Views/Onboarding/WhatsNewView.swift | 48 +++++ .../Views/UserSettings/SettingsView.swift | 20 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 36 ++++ apps/ios/SimpleXChat/ChatTypes.swift | 11 + .../chat/simplex/common/model/SimpleXAPI.kt | 3 + .../common/views/badges/BadgeUserPreview.kt | 41 ++++ .../views/badges/BadgesHowItWorksView.kt | 34 ++++ .../common/views/badges/BadgesPayView.kt | 154 ++++++++++++++ .../views/badges/BadgesRedeemCodeView.kt | 29 +++ .../views/badges/BadgesSupportSimplexView.kt | 141 +++++++++++++ .../views/badges/BadgesYourLevelView.kt | 157 +++++++++++++++ .../views/badges/SupportSimpleXBanner.kt | 173 ++++++++++++++++ .../common/views/chatlist/ChatListView.kt | 28 ++- .../common/views/helpers/ChatInfoImage.kt | 2 +- .../common/views/onboarding/HowItWorks.kt | 10 +- .../common/views/onboarding/SimpleXInfo.kt | 6 +- .../common/views/onboarding/WhatsNewView.kt | 47 +++++ .../common/views/usersettings/SettingsView.kt | 12 ++ .../commonMain/resources/MR/base/strings.xml | 32 +++ .../default/MR/images/phone_supporter.svg | 4 + .../MR/images/phone_supporter_light.svg | 4 + 31 files changed, 1750 insertions(+), 14 deletions(-) create mode 100644 apps/ios/Shared/Views/Badges/BadgeUserPreview.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesPayView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift create mode 100644 apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift create mode 100644 apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg create mode 100644 apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg diff --git a/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift new file mode 100644 index 0000000000..3e7216866e --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgeUserPreview.swift @@ -0,0 +1,39 @@ +// +// BadgeUserPreview.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 30.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgeUserPreview: View { + @EnvironmentObject var chatModel: ChatModel + let level: BadgeLevel + let trailing: () -> Trailing + + init(level: BadgeLevel, @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }) { + self.level = level + self.trailing = trailing + } + + var body: some View { + let user = chatModel.currentUser + let displayName = user?.displayName ?? NSLocalizedString("My nickname", comment: "badges preview placeholder") + let previewBadge = LocalBadge( + badge: BadgeInfo(badgeType: level.badgeType), + status: .active + ) + return VStack(spacing: 12) { + ProfileImage(imageStr: user?.image, size: 128) + HStack(alignment: .center, spacing: 6) { + NameWithBadge(Text(displayName).font(.largeTitle), previewBadge, .largeTitle) + .lineLimit(1) + .minimumScaleFactor(0.75) + trailing() + } + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift new file mode 100644 index 0000000000..24836b37f9 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesHowItWorksView.swift @@ -0,0 +1,46 @@ +// +// BadgesHowItWorksView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace lorem ipsum with the real copy once the badge protocol and privacy properties are documented. +struct BadgesHowItWorksView: View { + @EnvironmentObject var theme: AppTheme + + var body: some View { + VStack(alignment: .leading) { + Text("How private badges work") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .padding(.top, 8) + .padding(.bottom, 16) + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.") + Text("Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.") + Text("Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.") + } + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + Spacer() + } + .padding(.horizontal, 25) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +struct BadgesHowItWorksView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesHowItWorksView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesPayView.swift b/apps/ios/Shared/Views/Badges/BadgesPayView.swift new file mode 100644 index 0000000000..b32c14485f --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesPayView.swift @@ -0,0 +1,146 @@ +// +// BadgesPayView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum BadgePeriod: String, CaseIterable, Identifiable { + case oneMonth + case subscribe + + var id: String { rawValue } + + var icon: String { + switch self { + case .oneMonth: "calendar" + case .subscribe: "arrow.clockwise" + } + } + + var label: LocalizedStringKey { + switch self { + case .oneMonth: "1 month" + case .subscribe: "Subscribe" + } + } +} + +struct BadgesPayView: View { + @EnvironmentObject var theme: AppTheme + let level: BadgeLevel + @State private var selectedPeriod: BadgePeriod = .subscribe + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text(level.title) + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + BadgeUserPreview(level: level) + .padding(.top, 4) + + Text(level.tagline) + .font(.body) + .foregroundColor(theme.colors.onBackground) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 4) + + Spacer(minLength: 20) + + HStack(alignment: .top, spacing: 12) { + periodCard(.oneMonth) + periodCard(.subscribe) + } + + Spacer(minLength: 20) + + VStack(spacing: 10) { + payButton() + .padding(.vertical, 10) + Text(billingFooter) + .font(.footnote) + .foregroundColor(theme.colors.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func periodCard(_ period: BadgePeriod) -> some View { + let isSelected = period == selectedPeriod + return Button { + selectedPeriod = period + } label: { + VStack(spacing: 12) { + Image(systemName: period.icon) + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + .foregroundColor(isSelected ? theme.colors.primary : theme.colors.secondary) + .padding(.top, 30) + Text(period.label) + .font(.title3) + .fontWeight(.bold) + .padding(.bottom, 30) + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + + private func payButton() -> some View { + Button { + // TODO [badges] wire to purchase API when it lands. + } label: { + Text(selectedPeriod == .subscribe ? "Pay \(level.priceAmount)/month" : "Pay \(level.priceAmount)") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + } + + private var billingFooter: LocalizedStringKey { + // TODO [badges] source the actual date from the purchase state machine when wired. + var comps = DateComponents(); comps.year = 2026; comps.month = 7; comps.day = 22 + let stubDate = Calendar.current.date(from: comps) ?? Date() + let date = DateFormatter.localizedString(from: stubDate, dateStyle: .long, timeStyle: .none) + switch selectedPeriod { + case .subscribe: return "Renews on \(date). Cancel anytime." + case .oneMonth: return "Ends on \(date)." + } + } +} + +struct BadgesPayView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesPayView(level: .supporter) + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift new file mode 100644 index 0000000000..2101bfa7f0 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesRedeemCodeView.swift @@ -0,0 +1,36 @@ +// +// BadgesRedeemCodeView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: implement input field, server verification and success/failure states when the redeem API is defined. +struct BadgesRedeemCodeView: View { + @EnvironmentObject var theme: AppTheme + + var body: some View { + VStack(alignment: .leading) { + Text("Redeem badge code") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .padding(.top, 8) + Spacer() + } + .padding(.horizontal, 25) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +struct BadgesRedeemCodeView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesRedeemCodeView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift new file mode 100644 index 0000000000..6ae3176e85 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesSupportSimplexView.swift @@ -0,0 +1,165 @@ +// +// BadgesSupportSimplexView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct BadgesSupportSimplexView: View { + @EnvironmentObject var theme: AppTheme + // set true when presented as a sheet root (from the chat-list banner) — that path doesn't + // reserve nav-bar space like a NavigationLink push does, so the title lands too close to the top + var showsAsSheet: Bool = false + @State private var whyBuiltActive = false + @State private var chooseLevelActive = false + @State private var redeemCodeActive = false + + var body: some View { + // TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge") + GeometryReader { g in + VStack(alignment: .center, spacing: 16) { + Text("Support SimpleX") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + Text("SimpleX doesn't sell ads or data. It's funded by its users and by investors who share the mission. You can support the project and show a badge on your profile.") + .font(.body) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + whyBuiltButton() + + Spacer(minLength: 0) + + PhoneSupporterHero() + .frame(maxWidth: g.size.width * 0.55) + .layoutPriority(-1) + + Spacer(minLength: 0) + + // Onboarding pattern: nested VStack(spacing: 10) + action button vertical padding 10. + VStack(spacing: 10) { + chooseLevelButton() + .padding(.vertical, 10) + redeemCodeButton() + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, showsAsSheet ? 48 : 0) + .padding(.bottom, 20) + // .frame(height:) not minHeight — inside the banner sheet's NavigationView minHeight + // would let the VStack expand past the visible area and inflate the hero. + .frame(height: g.size.height) + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func whyBuiltButton() -> some View { + ZStack { + Button { whyBuiltActive = true } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("Why SimpleX is built.").fontWeight(.medium) + } + .font(.body) + } + NavigationLink(isActive: $whyBuiltActive) { + WhySimpleX(onboarding: false, titleColor: theme.colors.primary, createProfileNavLinkActive: .constant(false)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func chooseLevelButton() -> some View { + ZStack { + Button { + chooseLevelActive = true + } label: { + Text("Choose your level") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $chooseLevelActive) { + BadgesYourLevelView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func redeemCodeButton() -> some View { + ZStack { + Button { + redeemCodeActive = true + } label: { + Text("Redeem badge code") + .font(.body) + .fontWeight(.medium) + .foregroundColor(theme.colors.primary) + } + + NavigationLink(isActive: $redeemCodeActive) { + BadgesRedeemCodeView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } +} + +struct PhoneSupporterHero: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + + var body: some View { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light") + .resizable() + .scaledToFit() + .frame(maxWidth: .infinity) + #else + ZStack { + let gp = OnboardingCardView.gradientPoints(aspectRatio: 1.0, scale: colorScheme == .light ? 1.2 : 1.5) + LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: gp.start, + endPoint: gp.end + ) + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 96) + } + .aspectRatio(1.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 24)) + .frame(maxWidth: .infinity) + #endif + } +} + +struct BadgesSupportSimplexView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesSupportSimplexView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift new file mode 100644 index 0000000000..3cfd576ea9 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/BadgesYourLevelView.swift @@ -0,0 +1,188 @@ +// +// BadgesYourLevelView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum BadgeLevel: String, CaseIterable, Identifiable { + case supporter + case legend + + var id: String { rawValue } + + var title: LocalizedStringKey { + switch self { + case .supporter: "Supporter" + case .legend: "Legend" + } + } + + var filesDescription: LocalizedStringKey { + switch self { + case .supporter: "Send 2GB files" + case .legend: "Send 5GB files" + } + } + + var priceAmount: String { + switch self { + case .supporter: "$7" + case .legend: "$70" + } + } + + var tagline: LocalizedStringKey { + switch self { + case .supporter: "Optional profile badge\nand 2GB files" + case .legend: "Optional profile badge\nand 5GB files" + } + } + + var badgeType: BadgeType { + switch self { + case .supporter: .supporter + case .legend: .legend + } + } +} + +struct BadgesYourLevelView: View { + @EnvironmentObject var theme: AppTheme + @State private var selectedLevel: BadgeLevel = .supporter + @State private var continueActive = false + @State private var howItWorksActive = false + + var body: some View { + GeometryReader { g in + ScrollView { + VStack(alignment: .center, spacing: 16) { + Text("Your level") + .font(.largeTitle) + .bold() + .foregroundColor(theme.colors.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + + BadgeUserPreview(level: selectedLevel) { + Image(systemName: "chevron.down") + .font(.body) + .foregroundColor(theme.colors.primary) + } + .padding(.top, 4) + + Spacer(minLength: 20) + + HStack(alignment: .top, spacing: 12) { + levelCard(.supporter) + levelCard(.legend) + } + + Spacer(minLength: 20) + + VStack(spacing: 10) { + continueButton() + .padding(.vertical, 10) + howItWorksButton() + .frame(height: 22) + } + .padding(.bottom, g.safeAreaInsets.bottom == 0 ? 20 : 0) + } + .padding(.horizontal, 25) + .padding(.top, 0) + .padding(.bottom, 20) + .frame(minHeight: g.size.height) + } + } + .frame(maxHeight: .infinity) + .navigationBarTitleDisplayMode(.inline) + } + + private func levelCard(_ level: BadgeLevel) -> some View { + let isSelected = level == selectedLevel + return Button { + selectedLevel = level + } label: { + VStack(spacing: 10) { + Image(badgeImageName(level.badgeType)) + .resizable() + .scaledToFit() + .frame(width: 60, height: 60) + .padding(.top, 20) + Text(level.title) + .font(.title3) + .fontWeight(.bold) + Text(level.filesDescription) + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + Text("\(level.priceAmount)/month") + .font(.body) + .padding(.bottom, 20) + } + .frame(maxWidth: .infinity) + .background(Color(uiColor: .secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isSelected ? theme.colors.primary : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + } + .buttonStyle(.plain) + } + + private func continueButton() -> some View { + ZStack { + Button { + continueActive = true + } label: { + Text("Continue") + } + .buttonStyle(OnboardingButtonStyle(isDisabled: false)) + + NavigationLink(isActive: $continueActive) { + BadgesPayView(level: selectedLevel) + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } + + private func howItWorksButton() -> some View { + ZStack { + Button { + howItWorksActive = true + } label: { + HStack(spacing: 4) { + Image(systemName: "info.circle") + Text("How private badges work").fontWeight(.medium) + } + .font(.body) + } + + NavigationLink(isActive: $howItWorksActive) { + BadgesHowItWorksView() + .modifier(ThemedBackground()) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + } +} + +struct BadgesYourLevelView_Previews: PreviewProvider { + static var previews: some View { + NavigationView { + BadgesYourLevelView() + } + } +} diff --git a/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift new file mode 100644 index 0000000000..07bf4366d2 --- /dev/null +++ b/apps/ios/Shared/Views/Badges/SupportSimpleXBanner.swift @@ -0,0 +1,129 @@ +// +// SupportSimpleXBanner.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 28.07.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SupportSimpleXBanner: View { + @EnvironmentObject var theme: AppTheme + @Environment(\.colorScheme) var colorScheme: ColorScheme + @State private var showDismissAlert = false + let onTap: () -> Void + let onDismiss: () -> Void + + private let cardCornerRadius: CGFloat = 16 + // grows with Dynamic Type but never shrinks below the default so small-font users see the same + // banner as today; hero stays fixed so its above-card overhang shrinks at very large fonts + @ScaledMetric(relativeTo: .body) private var scaledCardHeight: CGFloat = 72 + private var cardHeight: CGFloat { max(72, scaledCardHeight) } + // matches OneHandUICard's segment icon leading so the text aligns with it in the list + private let cardLeadingPadding: CGFloat = 16 + private let cardTrailingPadding: CGFloat = 8 + private let heroWidth: CGFloat = 110 + // shorter than the natural drawn height so .clipped() slices the phone body at card bottom + private let heroVisibleHeight: CGFloat = 108 + // hero right edge sits exactly at the dismiss X's left edge (X trailing 16 + width 12) + private let heroTrailingPadding: CGFloat = 28 + private let textToHeroGap: CGFloat = 6 + + var body: some View { + // Card is the Button; hero is an overlay so it can extend above the card top without + // affecting layout size. Dismiss X is a ZStack sibling anchored to the card's top-right. + ZStack(alignment: .topTrailing) { + Button(action: onTap) { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Text("Support SimpleX") + .font(.headline) + .foregroundColor(theme.colors.primary) + .lineLimit(2) + Text("Get badge + files up to 5GB") + .font(.subheadline) + .foregroundColor(theme.colors.onBackground) + .lineLimit(2) + } + Spacer(minLength: heroWidth + heroTrailingPadding + textToHeroGap) + } + .padding(.leading, cardLeadingPadding) + .padding(.trailing, cardTrailingPadding) + .padding(.vertical, 12) + .frame(minHeight: cardHeight) + .background(gradientBackground()) + .clipShape(RoundedRectangle(cornerRadius: cardCornerRadius)) + } + .buttonStyle(.plain) + .overlay(alignment: .bottomTrailing) { + heroThumbnail() + .padding(.trailing, heroTrailingPadding) + .allowsHitTesting(false) + } + + Image(systemName: "multiply") + .foregroundColor(colorScheme == .dark ? theme.colors.onBackground : theme.colors.secondary) + .frame(width: 12, height: 12) + .padding(.top, 12) + .padding(.bottom, 4) + .padding(.trailing, 16) + .padding(.leading, 4) + .contentShape(Rectangle()) + .onTapGesture { showDismissAlert = true } + } + .alert(isPresented: $showDismissAlert) { + Alert( + title: Text("Support SimpleX"), + message: Text("You can support SimpleX later in Settings."), + dismissButton: .default(Text("Ok"), action: onDismiss) + ) + } + } + + @ViewBuilder + private func heroThumbnail() -> some View { + #if SIMPLEX_ASSETS + // draws at natural aspect, top-aligned in a shorter slot; .clipped() cuts the overflow at card bottom + Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: heroWidth, height: heroVisibleHeight, alignment: .top) + .clipped() + #else + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 48, height: 48) + .padding(.vertical, (cardHeight - 48) / 2) + .padding(.trailing, 12) + #endif + } + + private func gradientBackground() -> some View { + // Asymmetric scale: start (dark end) pushed further below the card than the end (warm) is + // above, so the card's middle lands at the bright/mid-transition stop instead of the dark + // navy region. Keeps the small warm accent at top-right. + GeometryReader { geo in + let aspect = max(geo.size.height, 1) / max(geo.size.width, 1) + let startScale: CGFloat = colorScheme == .light ? 2.5 : 3.0 + let endScale: CGFloat = colorScheme == .light ? 1.7 : 2.1 + let gp = OnboardingCardView.gradientPoints(aspectRatio: aspect, scale: 1.0) + let start = UnitPoint(x: 0.5 + (gp.start.x - 0.5) * startScale, y: 0.5 + (gp.start.y - 0.5) * startScale) + let end = UnitPoint(x: 0.5 + (gp.end.x - 0.5) * endScale, y: 0.5 + (gp.end.y - 0.5) * endScale) + return LinearGradient( + stops: colorScheme == .light ? OnboardingCardView.lightStops : OnboardingCardView.darkStops, + startPoint: start, + endPoint: end + ) + } + } +} + +struct SupportSimpleXBanner_Previews: PreviewProvider { + static var previews: some View { + SupportSimpleXBanner(onTap: {}, onDismiss: {}) + .padding() + } +} diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index b05e0696e3..f8745a7eaf 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -174,7 +174,9 @@ struct ChatListView: View { @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false + @AppStorage(DEFAULT_SUPPORTER_BANNER_SHOWN) private var supporterBannerShown = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + @State private var showBadgesSheet = false // Spec: spec/client/chat-list.md#body var body: some View { @@ -211,6 +213,12 @@ struct ChatListView: View { NewChatSheet() .environment(\EnvironmentValues.refresh as! WritableKeyPath, nil) } + .appSheet(isPresented: $showBadgesSheet) { + NavigationView { + BadgesSupportSimplexView(showsAsSheet: true) + .modifier(ThemedBackground()) + } + } .onChange(of: activeUserPickerSheet) { if $0 != nil { DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { @@ -418,6 +426,17 @@ struct ChatListView: View { .listRowSeparator(.hidden) .listRowBackground(Color.clear) } + if !supporterBannerShown && chatModel.chats.count > 3 { + SupportSimpleXBanner( + onTap: { showBadgesSheet = true }, + onDismiss: { withAnimation { supporterBannerShown = true } } + ) + .padding(.vertical, 3) + .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .zIndex(1) + } if #available(iOS 16.0, *) { ForEach(cs, id: \.viewId) { chat in ChatListNavLink(chat: chat, parentSheet: $sheet) diff --git a/apps/ios/Shared/Views/Helpers/NameBadge.swift b/apps/ios/Shared/Views/Helpers/NameBadge.swift index 67f6d6d6b2..11a4dafd2f 100644 --- a/apps/ios/Shared/Views/Helpers/NameBadge.swift +++ b/apps/ios/Shared/Views/Helpers/NameBadge.swift @@ -95,7 +95,7 @@ struct NameBadge: View { } } -private func badgeImageName(_ t: BadgeType) -> String { +func badgeImageName(_ t: BadgeType) -> String { switch t { case .legend: "badge-legend" case .investor: "badge-investor" diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index e9b9c6b970..dd01534dfb 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -65,6 +65,7 @@ struct WhySimpleX: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var m: ChatModel var onboarding: Bool + var titleColor: Color? = nil @Binding var createProfileNavLinkActive: Bool var body: some View { @@ -74,6 +75,7 @@ struct WhySimpleX: View { Text("You were born without an account") .font(.title) .bold() + .foregroundColor(titleColor) .padding(.top) Text("Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life.") Text("Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible.") diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 6ea24ec91c..9271566676 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -695,6 +695,18 @@ private let versionDescriptions: [VersionDescription] = [ )) ] ), + // TODO [badges] finalise copy + Read more link before v7.1 ships. + VersionDescription( + version: "v7.1", + post: nil, + features: [ + .view(FeatureView( + icon: nil, + title: "Supporter badge", + view: { SupporterBadgeWhatsNew() } + )) + ] + ), ] private let lastVersion = versionDescriptions.last!.version @@ -709,6 +721,42 @@ func shouldShowWhatsNew() -> Bool { return v != lastVersion } +fileprivate struct SupporterBadgeWhatsNew: View { + @Environment(\.colorScheme) var colorScheme: ColorScheme + @EnvironmentObject var theme: AppTheme + + var body: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + Text("Supporter badge ❤️") + .font(.title3) + .bold() + Text("Help keep the network running — send files up to 2 GB.") + .multilineTextAlignment(.leading) + .lineLimit(10) + } + .frame(maxWidth: .infinity, alignment: .leading) + + heroThumbnail() + } + } + + @ViewBuilder + private func heroThumbnail() -> some View { + #if SIMPLEX_ASSETS + Image(colorScheme == .light ? "phone-supporter" : "phone-supporter-light") + .resizable() + .scaledToFit() + .frame(width: 110, height: 110) + #else + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 72, height: 72) + #endif + } +} + fileprivate struct NewOperatorsView: View { var body: some View { VStack(alignment: .leading) { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 4bd5f7db1b..012142d9ba 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -56,6 +56,7 @@ let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness" let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" +let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown" let DEFAULT_TOOLBAR_MATERIAL = "toolbarMaterial" let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab" let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown" @@ -117,6 +118,7 @@ let appDefaults: [String: Any] = [ DEFAULT_CHAT_ITEM_TAIL: true, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false, + DEFAULT_SUPPORTER_BANNER_SHOWN: false, DEFAULT_TOOLBAR_MATERIAL: ToolbarMaterial.defaultMaterial, DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false, @@ -148,6 +150,7 @@ let hintDefaults = [ DEFAULT_LA_NOTICE_SHOWN, DEFAULT_ONE_HAND_UI_CARD_SHOWN, DEFAULT_ADDRESS_CREATION_CARD_SHOWN, + DEFAULT_SUPPORTER_BANNER_SHOWN, DEFAULT_LIVE_MESSAGE_ALERT_SHOWN, DEFAULT_SIGN_MESSAGE_ALERT_SHOWN, DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE, @@ -341,6 +344,21 @@ struct SettingsView: View { } } + Section { + NavigationLink { + BadgesSupportSimplexView() + .modifier(ThemedBackground()) + } label: { + ZStack(alignment: .leading) { + Image("badge-supporter") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + Text("Supporter perks").padding(.leading, indent) + } + } + } + Section(header: Text("Advanced settings").foregroundColor(theme.colors.secondary)) { NavigationLink { NetworkAndServers() @@ -375,7 +393,7 @@ struct SettingsView: View { NavigationLink { VersionView() .navigationBarTitle("App version") - .modifier(ThemedBackground()) + .modifier(ThemedBackground(grouped: true)) } label: { Text(verbatim: "v\(appVersion ?? "?")") } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 3b0c6ed57d..211b53e4f7 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -145,6 +145,13 @@ 640417CE2B29B8C200CCB412 /* NewChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640417CC2B29B8C200CCB412 /* NewChatView.swift */; }; 640743612CD360E600158442 /* ChooseServerOperators.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640743602CD360E600158442 /* ChooseServerOperators.swift */; }; 6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; }; + 641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */; }; + 641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */; }; + 641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */; }; + 641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */; }; + 641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */; }; + 641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */; }; + 641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 641377FB3020A5AD0056E083 /* BadgesPayView.swift */; }; 6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; }; 642BA82D2CE50495005E9412 /* NewServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642BA82C2CE50495005E9412 /* NewServerView.swift */; }; 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; }; @@ -524,6 +531,13 @@ 640417CC2B29B8C200CCB412 /* NewChatView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewChatView.swift; sourceTree = ""; }; 640743602CD360E600158442 /* ChooseServerOperators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChooseServerOperators.swift; sourceTree = ""; }; 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = ""; }; + 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesHowItWorksView.swift; sourceTree = ""; }; + 641377FB3020A5AD0056E083 /* BadgesPayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesPayView.swift; sourceTree = ""; }; + 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesRedeemCodeView.swift; sourceTree = ""; }; + 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesSupportSimplexView.swift; sourceTree = ""; }; + 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesYourLevelView.swift; sourceTree = ""; }; + 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeUserPreview.swift; sourceTree = ""; }; + 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportSimpleXBanner.swift; sourceTree = ""; }; 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = ""; }; 642BA82C2CE50495005E9412 /* NewServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewServerView.swift; sourceTree = ""; }; 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = ""; }; @@ -784,6 +798,7 @@ 8C7D94982B8894D300B7B9E1 /* Migration */, 5CA8D01B2AD9B076001FD661 /* RemoteAccess */, 5CB924DF27A8678B00ACCCDD /* UserSettings */, + E5BADE0100000000BADE0000 /* Badges */, 5C2E261127A30FEA00F70299 /* TerminalView.swift */, ); path = Views; @@ -1238,6 +1253,20 @@ path = "SimpleX SE"; sourceTree = ""; }; + E5BADE0100000000BADE0000 /* Badges */ = { + isa = PBXGroup; + children = ( + 641377FA3020A5AD0056E083 /* BadgesHowItWorksView.swift */, + 641377FB3020A5AD0056E083 /* BadgesPayView.swift */, + 641377FC3020A5AD0056E083 /* BadgesRedeemCodeView.swift */, + 641377FD3020A5AD0056E083 /* BadgesSupportSimplexView.swift */, + 641377FE3020A5AD0056E083 /* BadgesYourLevelView.swift */, + 641377FF3020A5AD0056E083 /* BadgeUserPreview.swift */, + 641378003020A5AD0056E083 /* SupportSimpleXBanner.swift */, + ); + path = Badges; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1693,6 +1722,13 @@ 1841538E296606C74533367C /* UserPicker.swift in Sources */, 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, + 641378013020A5AD0056E083 /* BadgesRedeemCodeView.swift in Sources */, + 641378023020A5AD0056E083 /* SupportSimpleXBanner.swift in Sources */, + 641378033020A5AD0056E083 /* BadgesHowItWorksView.swift in Sources */, + 641378043020A5AD0056E083 /* BadgesSupportSimplexView.swift in Sources */, + 641378053020A5AD0056E083 /* BadgesYourLevelView.swift in Sources */, + 641378063020A5AD0056E083 /* BadgeUserPreview.swift in Sources */, + 641378073020A5AD0056E083 /* BadgesPayView.swift in Sources */, 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */, 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ddc9454b86..fae43f9069 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -302,11 +302,22 @@ public struct BadgeInfo: Codable, Hashable { public var badgeType: BadgeType public var badgeExpiry: Date? public var badgeExtra: String + + public init(badgeType: BadgeType, badgeExpiry: Date? = nil, badgeExtra: String = "") { + self.badgeType = badgeType + self.badgeExpiry = badgeExpiry + self.badgeExtra = badgeExtra + } } public struct LocalBadge: Codable, Hashable { public var badge: BadgeInfo public var status: BadgeStatus + + public init(badge: BadgeInfo, status: BadgeStatus) { + self.badge = badge + self.status = status + } } // the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 037c02b1c2..436d7e8059 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -192,6 +192,7 @@ class AppPreferences { val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true) val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false) val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false) + val supporterBannerShown = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_SHOWN, false) val showMuteProfileAlert = mkBoolPreference(SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT, true) val showReportsInSupportChatAlert = mkBoolPreference(SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT, true) val appLanguage = mkStrPreference(SHARED_PREFS_APP_LANGUAGE, null) @@ -273,6 +274,7 @@ class AppPreferences { hintPref(laNoticeShown, false), hintPref(oneHandUICardShown, false), hintPref(addressCreationCardShown, false), + hintPref(supporterBannerShown, false), hintPref(liveMessageAlertShown, false), hintPref(signMessageAlertShown, false), hintPref(showHiddenProfilesNotice, true), @@ -464,6 +466,7 @@ class AppPreferences { private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice" private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown" private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown" + private const val SHARED_PREFS_SUPPORTER_BANNER_SHOWN = "SupporterBannerShown" private const val SHARED_PREFS_SHOW_MUTE_PROFILE_ALERT = "ShowMuteProfileAlert" private const val SHARED_PREFS_SHOW_REPORTS_IN_SUPPORT_CHAT_ALERT = "ShowReportsInSupportChatAlert" private const val SHARED_PREFS_STORE_DB_PASSPHRASE = "StoreDBPassphrase" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt new file mode 100644 index 0000000000..6380e5cf79 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgeUserPreview.kt @@ -0,0 +1,41 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeInfo +import chat.simplex.common.model.BadgeStatus +import chat.simplex.common.model.LocalBadge +import chat.simplex.common.platform.chatModel +import chat.simplex.common.views.helpers.NameWithBadge +import chat.simplex.common.views.helpers.ProfileImage +import chat.simplex.res.MR + +@Composable +fun BadgeUserPreview(level: BadgeLevel, modifier: Modifier = Modifier, trailing: @Composable () -> Unit = {}) { + val user = chatModel.currentUser.value + val displayName = user?.displayName ?: stringResource(MR.strings.badges_preview_my_nickname) + val previewBadge = LocalBadge( + badge = BadgeInfo(badgeType = level.badgeType), + status = BadgeStatus.Active + ) + Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + ProfileImage(size = 128.dp, image = user?.image) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + NameWithBadge( + name = displayName, + badge = previewBadge, + style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + trailing() + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt new file mode 100644 index 0000000000..7c16bee763 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesHowItWorksView.kt @@ -0,0 +1,34 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.res.MR + +// TODO [badges]: replace lorem ipsum with the real copy once the badge protocol and privacy properties are documented. +@Composable +fun BadgesHowItWorksView() { + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + stringResource(MR.strings.badges_how_it_works_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + modifier = Modifier.padding(bottom = 16.dp) + ) + Text(stringResource(MR.strings.badges_how_it_works_p1), style = MaterialTheme.typography.body1) + Text(stringResource(MR.strings.badges_how_it_works_p2), style = MaterialTheme.typography.body1) + Text(stringResource(MR.strings.badges_how_it_works_p3), style = MaterialTheme.typography.body1) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt new file mode 100644 index 0000000000..742b03e2bd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesPayView.kt @@ -0,0 +1,154 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.res.MR + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum class BadgePeriod { + OneMonth, + Subscribe; + + val icon: dev.icerock.moko.resources.ImageResource + get() = when (this) { + OneMonth -> MR.images.ic_calendar + Subscribe -> MR.images.ic_refresh + } + + val label: StringResource + get() = when (this) { + OneMonth -> MR.strings.badges_period_one_month + Subscribe -> MR.strings.badges_period_subscribe + } +} + +@Composable +fun BadgesPayView(level: BadgeLevel) { + var selectedPeriod by remember { mutableStateOf(BadgePeriod.Subscribe) } + + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(level.title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + BadgeUserPreview(level = level, modifier = Modifier.padding(top = 4.dp)) + + Text( + stringResource(level.tagline), + style = MaterialTheme.typography.body1, + color = MaterialTheme.colors.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + ) + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height + // when 2-line labels at large fonts would otherwise size them differently. + Row( + Modifier.fillMaxWidth().height(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + PeriodCard(BadgePeriod.OneMonth, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + PeriodCard(BadgePeriod.Subscribe, selectedPeriod, Modifier.weight(1f).fillMaxHeight()) { selectedPeriod = it } + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // Replicates TextButtonBelowOnboardingButton spacing (7.5dp outer + 5dp inner) without a + // TextButton so the footer has no hover/click affordance. + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PayButton(level, selectedPeriod) + Box(Modifier.padding(top = 7.5.dp, bottom = 7.5.dp).padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + stringResource(billingFooter(selectedPeriod)).format(stubBillingDate()), + Modifier.padding(vertical = 5.dp), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + textAlign = TextAlign.Center + ) + } + } + } +} + +@Composable +private fun PeriodCard(period: BadgePeriod, selectedPeriod: BadgePeriod, modifier: Modifier, onSelect: (BadgePeriod) -> Unit) { + val isSelected = period == selectedPeriod + val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) + // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. + val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + else MaterialTheme.colors.background + val shape = RoundedCornerShape(16.dp) + Column( + modifier + .clip(shape) + .background(cardBackground, shape) + .border(2.dp, borderColor, shape) + .clickable { onSelect(period) } + .padding(vertical = 30.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + painterResource(period.icon), + contentDescription = null, + tint = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + modifier = Modifier.size(32.dp) + ) + Text(stringResource(period.label), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + } +} + +@Composable +private fun PayButton(level: BadgeLevel, selectedPeriod: BadgePeriod) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = if (selectedPeriod == BadgePeriod.Subscribe) MR.strings.badges_pay_monthly else MR.strings.badges_pay_once, + labelArg = level.priceAmount, + onboarding = null, + onclick = { + // TODO [badges] wire to purchase API when it lands. + } + ) +} + +private fun billingFooter(period: BadgePeriod): StringResource = when (period) { + BadgePeriod.Subscribe -> MR.strings.badges_billing_footer_subscribe + BadgePeriod.OneMonth -> MR.strings.badges_billing_footer_one_month +} + +// TODO [badges] source the actual date from the purchase state machine when wired. +private fun stubBillingDate(): String { + val date = java.time.LocalDate.of(2026, 7, 22) + val formatter = java.time.format.DateTimeFormatter.ofLocalizedDate(java.time.format.FormatStyle.LONG) + return date.format(formatter) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt new file mode 100644 index 0000000000..8a1069c536 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesRedeemCodeView.kt @@ -0,0 +1,29 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.layout.* +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.platform.ColumnWithScrollBar +import chat.simplex.res.MR + +// TODO [badges]: implement input field, server verification and success/failure states when the redeem API is defined. +@Composable +fun BadgesRedeemCodeView() { + ColumnWithScrollBar( + Modifier.padding(horizontal = 25.dp).padding(top = 8.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + stringResource(MR.strings.badges_redeem_code_button), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary + ) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt new file mode 100644 index 0000000000..aa8100519b --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesSupportSimplexView.kt @@ -0,0 +1,141 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +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.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +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.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.common.views.onboarding.HowItWorks +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.MR + +@Composable +fun BadgesSupportSimplexView() { + // TODO [badges] gate on user badge status (no badge → this view, active → "Manage your badge") + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_support_simplex_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + Text( + stringResource(MR.strings.badges_support_simplex_body), + style = MaterialTheme.typography.body1, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + val primary = MaterialTheme.colors.primary + TextButton({ + ModalManager.start.showModal { HowItWorks(user = chatModel.currentUser.value, onboardingStage = null, titleColor = primary) } + }) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.primary) + Text(stringResource(MR.strings.badges_why_simplex_is_built), color = MaterialTheme.colors.primary, fontWeight = FontWeight.Medium) + } + } + + Spacer(Modifier.weight(1f)) + + PhoneSupporterHero(Modifier.fillMaxWidth(0.55f)) + + Spacer(Modifier.weight(1f)) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ChooseLevelButton() + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_redeem_code_button), + onClick = { ModalManager.start.showModal { BadgesRedeemCodeView() } } + ) + } + } +} + +@Composable +private fun ChooseLevelButton() { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_choose_your_level, + onboarding = null, + onclick = { + ModalManager.start.showModal { BadgesYourLevelView() } + } + ) +} + + +@Composable +fun PhoneSupporterHero(modifier: Modifier = Modifier) { + val isDark = isInDarkTheme() + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.phone_supporter_light else MR.images.phone_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier.fillMaxWidth() + ) + } else { + var size by remember { mutableStateOf(IntSize.Zero) } + val stops = if (isDark) darkStops else lightStops + val scale = if (isDark) 1.5f else 1.2f + val brush = remember(size, isDark) { + if (size.width > 0 && size.height > 0) { + val aspect = size.height.toFloat() / size.width.toFloat() + val gp = gradientPoints(aspect, scale) + Brush.linearGradient( + colorStops = stops, + start = Offset(gp.startX * size.width, gp.startY * size.height), + end = Offset(gp.endX * size.width, gp.endY * size.height) + ) + } else { + Brush.linearGradient(colorStops = stops) + } + } + Box( + modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(24.dp)) + .background(brush) + .onSizeChanged { size = it }, + contentAlignment = Alignment.Center + ) { + Image( + painterResource(MR.images.badge_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(96.dp) + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt new file mode 100644 index 0000000000..03082526a0 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/BadgesYourLevelView.kt @@ -0,0 +1,157 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +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.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import dev.icerock.moko.resources.StringResource +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import chat.simplex.common.model.BadgeType +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.onboarding.OnboardingActionButton +import chat.simplex.common.views.onboarding.TextButtonBelowOnboardingButton +import chat.simplex.res.MR + +// TODO [badges]: replace with types produced by the badge purchase API when it lands. +enum class BadgeLevel { + Supporter, + Legend; + + val title: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter + Legend -> MR.strings.badges_level_legend + } + + val filesDescription: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter_files + Legend -> MR.strings.badges_level_legend_files + } + + val priceAmount: String + get() = when (this) { + Supporter -> "$7" + Legend -> "$70" + } + + val tagline: StringResource + get() = when (this) { + Supporter -> MR.strings.badges_level_supporter_tagline + Legend -> MR.strings.badges_level_legend_tagline + } + + val badgeType: BadgeType + get() = when (this) { + Supporter -> BadgeType.Supporter + Legend -> BadgeType.Legend + } +} + +@Composable +fun BadgesYourLevelView() { + var selectedLevel by remember { mutableStateOf(BadgeLevel.Supporter) } + + ColumnWithScrollBar( + Modifier.background(MaterialTheme.colors.background).padding(horizontal = 25.dp).padding(top = 8.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + maxIntrinsicSize = true, + ) { + Text( + stringResource(MR.strings.badges_your_level_title), + style = MaterialTheme.typography.h1, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.primary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + + BadgeUserPreview(level = selectedLevel, modifier = Modifier.padding(top = 4.dp)) { + Icon( + painterResource(MR.images.ic_keyboard_arrow_down), + contentDescription = null, + tint = MaterialTheme.colors.primary + ) + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // IntrinsicSize.Max + fillMaxHeight on children so both cards match the taller card's height + // when 2-line labels at large fonts would otherwise size them differently. + Row( + Modifier.fillMaxWidth().height(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + LevelCard(BadgeLevel.Supporter, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it } + LevelCard(BadgeLevel.Legend, selectedLevel, Modifier.weight(1f).fillMaxHeight()) { selectedLevel = it } + } + + Spacer(Modifier.weight(1f).heightIn(min = 20.dp)) + + // Nested Column with no spacing so the TextButtonBelowOnboardingButton sits directly under + // the action button (matches onboarding pattern where its own 7.5dp top padding is the gap). + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ContinueButton(selectedLevel) + TextButtonBelowOnboardingButton( + text = stringResource(MR.strings.badges_how_it_works_button), + icon = painterResource(MR.images.ic_info), + onClick = { ModalManager.start.showModal { BadgesHowItWorksView() } } + ) + } + } +} + +@Composable +private fun LevelCard(level: BadgeLevel, selectedLevel: BadgeLevel, modifier: Modifier, onSelect: (BadgeLevel) -> Unit) { + val isSelected = level == selectedLevel + val borderColor = if (isSelected) MaterialTheme.colors.primary else MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.92f) + // Light: transparent so card matches page background. Dark: subtle gray tint for visible contrast. + val cardBackground = if (isInDarkTheme()) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) + else MaterialTheme.colors.background + val shape = RoundedCornerShape(16.dp) + Column( + modifier + .clip(shape) + .background(cardBackground, shape) + .border(2.dp, borderColor, shape) + .clickable { onSelect(level) } + .padding(vertical = 20.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Image( + painterResource(badgeImage(level.badgeType)), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(60.dp) + ) + Text(stringResource(level.title), style = MaterialTheme.typography.h3, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center) + Text(stringResource(level.filesDescription), style = MaterialTheme.typography.body2, color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) + Text(stringResource(MR.strings.badges_price_monthly).format(level.priceAmount), style = MaterialTheme.typography.body1, textAlign = TextAlign.Center) + } +} + +@Composable +private fun ContinueButton(selectedLevel: BadgeLevel) { + OnboardingActionButton( + modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.badges_continue, + onboarding = null, + onclick = { + ModalManager.start.showModal { BadgesPayView(selectedLevel) } + } + ) +} + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt new file mode 100644 index 0000000000..55a8cef525 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/badges/SupportSimpleXBanner.kt @@ -0,0 +1,173 @@ +package chat.simplex.common.views.badges + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +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.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.TextOverflow +import androidx.compose.ui.unit.Dp +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.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.darkStops +import chat.simplex.common.views.newchat.gradientPoints +import chat.simplex.common.views.newchat.lightStops +import chat.simplex.res.MR + +@Composable +fun SupportSimpleXBanner(onTap: () -> Unit, onDismiss: () -> Unit) { + val cardCornerRadius = 16.dp + // grows linearly with system font but never shrinks below the default so small-font users see the + // same baseline; the card Row uses heightIn(min = cardHeight) and grows further when 2-line text + // wraps at very large fonts. Hero stays fixed so its above-card overhang shrinks at very large fonts. + val cardHeight = (72.dp * fontSizeMultiplier).coerceAtLeast(72.dp) + // matches OneHandUICard's segment icon leading so the text aligns with it in the list + val cardLeadingPadding = 16.dp + val cardTrailingPadding = 8.dp + val heroWidth = 110.dp + // shorter than the natural drawn height so ContentScale.Crop slices the phone body at card bottom + val heroVisibleHeight = 108.dp + // hero right edge sits at the dismiss X's icon left edge (X: outer 4pt + inner-pad 8 + half of 16pt icon) + val heroTrailingPadding = 28.dp + val textToHeroGap = 6.dp + + val isDark = isInDarkTheme() + var cardSize by remember { mutableStateOf(IntSize.Zero) } + val brush = remember(isDark, cardSize) { gradientBrush(isDark, cardSize) } + + // Layout sizes to the card; hero is placed at y = cardHeight - heroHeight (negative → hero + // overhangs above card at normal fonts, 0/positive → hero fits inside card at large fonts). + Layout(content = { + Box(Modifier.fillMaxWidth()) { + Row( + Modifier + .fillMaxWidth() + .heightIn(min = cardHeight) + .clip(RoundedCornerShape(cardCornerRadius)) + .background(brush) + .clickable(onClick = onTap) + .onSizeChanged { cardSize = it } + .padding( + start = cardLeadingPadding, + end = cardTrailingPadding + heroWidth + heroTrailingPadding + textToHeroGap, + top = 12.dp, + bottom = 12.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(MR.strings.badges_banner_title), + style = MaterialTheme.typography.body1, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colors.primary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + stringResource(MR.strings.badges_banner_subtitle), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Same X pattern as OneHandUICard: circle-clipped clickable region with inner padding for hit area. + Icon( + painterResource(MR.images.ic_close), + contentDescription = stringResource(MR.strings.icon_descr_close_button), + tint = if (isDark) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = 4.dp, top = 4.dp) + .clip(CircleShape) + .clickable { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.badges_banner_title), + text = generalGetString(MR.strings.badges_banner_dismiss_message), + onConfirm = onDismiss + ) + } + .padding(8.dp) + .size(16.dp) + ) + } + + HeroThumbnail( + heroWidth = heroWidth, + heroVisibleHeight = heroVisibleHeight, + cardHeight = cardHeight, + trailingPadding = heroTrailingPadding + ) + }) { measurables, constraints -> + val cardPlaceable = measurables[0].measure(constraints) + val heroPlaceable = measurables[1].measure(constraints.copy(minWidth = 0, minHeight = 0)) + layout(cardPlaceable.width, cardPlaceable.height) { + cardPlaceable.place(0, 0) + heroPlaceable.place(cardPlaceable.width - heroPlaceable.width, cardPlaceable.height - heroPlaceable.height) + } + } +} + +@Composable +private fun HeroThumbnail(heroWidth: Dp, heroVisibleHeight: Dp, cardHeight: Dp, trailingPadding: Dp) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + // draws at natural aspect, top-aligned in a shorter slot; ContentScale.Crop cuts the overflow at card bottom + Image( + painterResource(if (isInDarkTheme()) MR.images.phone_supporter_light else MR.images.phone_supporter), + contentDescription = null, + contentScale = ContentScale.Crop, + alignment = Alignment.TopCenter, + modifier = Modifier.padding(end = trailingPadding).size(width = heroWidth, height = heroVisibleHeight) + ) + } else { + val badgeSize = 48.dp + Image( + painterResource(MR.images.badge_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(end = trailingPadding + 12.dp, top = (cardHeight - badgeSize) / 2, bottom = (cardHeight - badgeSize) / 2) + .size(badgeSize) + ) + } +} + +// Geometry-aware gradient with asymmetric scale: start (dark) pushed further below the card than +// end (warm) is above, so card-middle lands at the bright/mid-transition stop, not the dark region. +private fun gradientBrush(isDark: Boolean, size: IntSize): Brush { + val stops = if (isDark) darkStops else lightStops + if (size.width == 0 || size.height == 0) return Brush.linearGradient(colorStops = stops) + val w = size.width.toFloat() + val h = size.height.toFloat() + val startScale = if (isDark) 3.0f else 2.5f + val endScale = if (isDark) 2.1f else 1.7f + val gp = gradientPoints(h / w, 1.0f) + val sx = 0.5f + (gp.startX - 0.5f) * startScale + val sy = 0.5f + (gp.startY - 0.5f) * startScale + val ex = 0.5f + (gp.endX - 0.5f) * endScale + val ey = 0.5f + (gp.endY - 0.5f) * endScale + return Brush.linearGradient( + colorStops = stops, + start = Offset(sx * w, sy * h), + end = Offset(ex * w, ey * h) + ) +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 68fa25d553..19f50de743 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -41,6 +41,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.views.call.Call import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chat.topPaddingToContent +import chat.simplex.common.views.badges.* import chat.simplex.common.views.newchat.* import chat.simplex.common.views.onboarding.* import chat.simplex.common.views.usersettings.* @@ -912,6 +913,7 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat val oneHandUI = remember { appPrefs.oneHandUI.state } val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state } val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state } + val supporterBannerShown = remember { appPrefs.supporterBannerShown.state } val activeFilter = remember { chatModel.activeChatTagFilter } LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) { @@ -1000,6 +1002,16 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat ToggleChatListCard() } } + if (!supporterBannerShown.value && chatModel.chats.value.size > 3) { + item { + Box(Modifier.zIndex(1f).padding(16.dp)) { + SupportSimpleXBanner( + onTap = { ModalManager.start.showModal { BadgesSupportSimplexView() } }, + onDismiss = { appPrefs.supporterBannerShown.set(true) } + ) + } + } + } itemsIndexed(chats, key = { _, chat -> chat.remoteHostId to chat.id }) { index, chat -> val nextChatSelected = remember(chat.id, chats) { derivedStateOf { chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value @@ -1025,13 +1037,15 @@ private fun BoxScope.ChatList(searchText: MutableState, listStat } else { NavigationBarBackground(oneHandUI.value, true) } - if (!oneHandUICardShown.value) { - LaunchedEffect(chats.size) { - if (chats.size >= 3) { - appPrefs.oneHandUICardShown.set(true) - } - } - } + // TEMP-DISABLED-FOR-BADGES-QA: auto-hide of ToggleChatListCard at 3+ chats blocks visual QA of + // the SupportSimpleXBanner alongside it. Restore before merging. + // if (!oneHandUICardShown.value) { + // LaunchedEffect(chats.size) { + // if (chats.size >= 3) { + // appPrefs.oneHandUICardShown.set(true) + // } + // } + // } LaunchedEffect(activeFilter.value) { searchText.value = TextFieldValue("") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt index d2ee1db09c..567e575bbd 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatInfoImage.kt @@ -245,7 +245,7 @@ fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) } } -private fun badgeImage(t: BadgeType): ImageResource = when (t) { +fun badgeImage(t: BadgeType): ImageResource = when (t) { is BadgeType.Legend -> MR.images.badge_legend is BadgeType.Investor -> MR.images.badge_investor else -> MR.images.badge_supporter // Supporter + Unknown diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt index 703d295523..9d82395740 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/HowItWorks.kt @@ -7,6 +7,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.* @@ -22,12 +23,12 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource @Composable -fun HowItWorks(user: User?, onboardingStage: SharedPreference? = null) { +fun HowItWorks(user: User?, onboardingStage: SharedPreference? = null, titleColor: Color = Color.Unspecified) { Column(Modifier.fillMaxSize().padding(horizontal = if (appPlatform.isDesktop) DEFAULT_PADDING * 2 else DEFAULT_PADDING)) { Spacer(Modifier.statusBarsPadding().padding(top = AppBarHeight * fontSizeSqrtMultiplier)) val paraPadding = PaddingValues(bottom = if (appPlatform.isDesktop) 10.dp else 12.dp) Column(Modifier.weight(1f).padding(bottom = DEFAULT_PADDING).verticalScroll(rememberScrollState())) { - Text(stringResource(MR.strings.why_built_heading), style = MaterialTheme.typography.h1, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) + Text(stringResource(MR.strings.why_built_heading), style = MaterialTheme.typography.h1, color = titleColor, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) ReadableText(MR.strings.why_built_p1, padding = paraPadding) ReadableText(MR.strings.why_built_p2, padding = paraPadding) ReadableText(MR.strings.why_built_p3, padding = paraPadding) @@ -45,6 +46,11 @@ fun HowItWorks(user: User?, onboardingStage: SharedPreference? OnboardingActionButton(user, onboardingStage, onclick = { ModalManager.fullscreen.closeModal() }) TextButtonBelowOnboardingButton("", null) } + } else { + // No button below — add breathing room at the bottom on both platforms. + // Android also gets nav-bar inset so content doesn't run under the gesture bar. + Spacer(Modifier.height(DEFAULT_PADDING)) + if (appPlatform.isAndroid) Spacer(Modifier.navigationBarsPadding()) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt index 74dadcd671..d97d709953 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SimpleXInfo.kt @@ -190,12 +190,14 @@ expect fun OnboardingActionButton(user: User?, onboardingStage: SharedPreference fun OnboardingActionButton( modifier: Modifier = Modifier, labelId: StringResource, + labelArg: String? = null, onboarding: OnboardingStage?, enabled: Boolean = true, icon: Painter? = null, iconColor: Color = Color.White, onclick: (() -> Unit)? ) { + val label = if (labelArg != null) stringResource(labelId).format(labelArg) else stringResource(labelId) Button( onClick = { onclick?.invoke() @@ -211,9 +213,9 @@ fun OnboardingActionButton( colors = ButtonDefaults.buttonColors(MaterialTheme.colors.primary, disabledBackgroundColor = MaterialTheme.colors.secondary) ) { if (icon != null) { - Icon(icon, stringResource(labelId), Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor) + Icon(icon, label, Modifier.padding(end = DEFAULT_PADDING_HALF), tint = iconColor) } - Text(stringResource(labelId), style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) + Text(label, style = MaterialTheme.typography.h2, color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Medium) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index ea95bc2045..d3e1376af0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -940,8 +941,54 @@ private val versionDescriptions: List = listOf( ), ) ), + // TODO [badges] finalise copy + Read more link before v7.1 ships. + VersionDescription( + version = "v7.1", + post = null, + features = listOf( + VersionFeature.FeatureView( + icon = null, + titleId = MR.strings.v7_1_supporter_badge_title, + view = { SupporterBadgeWhatsNew() } + ) + ) + ), ) +@Composable +private fun SupporterBadgeWhatsNew() { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(MR.strings.v7_1_supporter_badge_title), + style = MaterialTheme.typography.h3, + fontWeight = FontWeight.Bold + ) + Text( + stringResource(MR.strings.v7_1_supporter_badge_body), + style = MaterialTheme.typography.body1, + maxLines = 10 + ) + } + val isDark = isInDarkTheme() + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(if (isDark) MR.images.phone_supporter_light else MR.images.phone_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(110.dp) + ) + } else { + Image( + painterResource(MR.images.badge_supporter), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.size(72.dp) + ) + } + } +} + private val lastVersion = versionDescriptions.last().version fun setLastVersionDefault(m: ChatModel) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index 96f36da6d7..e398a1b2fe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -26,6 +26,7 @@ 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.badges.BadgesSupportSimplexView import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrateFromDeviceView @@ -101,6 +102,17 @@ fun SettingsLayout( } SectionDividerSpaced() + SectionView { + // Direct showModal (no settings / cardScreen flags) — settings-style card chrome would render + // a gray top bar / back button that badges views don't want (they have their own inline titles). + SectionItemView(click = { ModalManager.start.showModal { BadgesSupportSimplexView() } }) { + Image(painterResource(MR.images.badge_supporter), stringResource(MR.strings.supporter_perks), Modifier.size(24.dp)) + TextIconSpaced() + Text(stringResource(MR.strings.supporter_perks)) + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.advanced_settings)) { SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showCustomModal { _, close -> NetworkAndServersView(close) }, disabled = stopped) if (appPlatform == AppPlatform.ANDROID) { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index ead51b31ea..8683af1705 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -3201,4 +3201,36 @@ This badge could not be verified and may not be genuine. Badge cannot be verified The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. + Supporter + Legend + Send 2GB files + Send 5GB files + %1$s/month + Pay %1$s/month + Pay %1$s + Optional profile badge\nand 2GB files + Optional profile badge\nand 5GB files + 1 month + Subscribe + Renews on %1$s. Cancel anytime. + Ends on %1$s. + Your level + Support SimpleX + SimpleX doesn\'t sell ads or data. It\'s funded by its users and by investors who share the mission. You can support the project and show a badge on your profile. + Why SimpleX is built. + Choose your level + Redeem badge code + Continue + How private badges work + How private badges work + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + My nickname + Support SimpleX + Get badge + files up to 5GB + You can support SimpleX later in Settings. + Supporter perks + Supporter badge ❤️ + Help keep the network running — send files up to 2 GB. \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/phone_supporter_light.svg @@ -0,0 +1,4 @@ + + + +