New adds

This commit is contained in:
Victor Bodinaud
2025-03-26 11:20:12 +01:00
parent b09cf50619
commit eb99d76108
29 changed files with 1573 additions and 2 deletions

View File

@@ -0,0 +1,62 @@
//
// MessageNotificationView.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 29/01/2025.
//
import SwiftUI
struct AVMessageNotification: Codable {
let displayName: String
let message: String
let avatarUrl: String
let actionUrl: String
let isNewUser: Bool
let countUnreadMessage: Int
enum CodingKeys: String, CodingKey {
case displayName = "display_name"
case message
case avatarUrl = "avatar_url"
case actionUrl = "action_url"
case isNewUser = "is_new_user"
case countUnreadMessage = "count_unread_message"
}
}
struct MessageNotificationView: View {
let notification: AVMessageNotification
var body: some View {
HStack(alignment: .center, spacing: 8) {
AVAvatar(model: AVAvatarModel(avatarString: notification.avatarUrl, onlineStatus: .online(), sizing: .M))
.frame(width: 48, height: 48)
VStack(alignment: .leading) {
SQText(notification.displayName, font: .demiBold)
SQText(notification.message, size: 14, textColor: .sqNeutral(70))
.lineLimit(1)
}
Spacer()
VStack {
Circle()
.frame(width: 12, height: 12)
.foregroundStyle(Color.sqSemanticBlue)
.padding(.top, 8)
Spacer()
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(height: 80)
.background {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white)
.shadow(color: .black.opacity(0.15), radius: 8, x: 0, y: 4)
}
}
}
#Preview {
MessageNotificationView(notification: AVMessageNotification(displayName: "Lucas E.", message: "Bonjour je voudrais une super voiture pour déplacer des ", avatarUrl: "https://public-allo10.allovoisins.com//assets/default_avatars/100/Avatar0.png", actionUrl: "", isNewUser: true, countUnreadMessage: 1))
}

View File

@@ -0,0 +1,367 @@
//
// AVAvatar.swift
// Allovoisins
//
// Created by Florian Baudin on 15/03/2023.
// Copyright © 2023 AlloVoisins. All rights reserved.
//
import SwiftUI
enum AVOnlineState {
case online
case recently
case offline
case hourlyDispo
}
class AVOnlineStatus: ObservableObject {
@Published var state: AVOnlineState!
@Published var lastConnection: Int?
init(state: AVOnlineState, lastConnection: Int? = nil) {
self.state = state
self.lastConnection = lastConnection
}
init(from lastOnlineMinutes: Int?, hourlyDispo: Bool = false) {
guard let lastOnlineMinutes else {
self.state = .offline
self.lastConnection = nil
return
}
switch lastOnlineMinutes {
case 0 ... 5:
self.state = .online
self.lastConnection = lastOnlineMinutes
case 5 ..< 60:
self.state = .recently
self.lastConnection = lastOnlineMinutes
default:
self.state = .offline
self.lastConnection = nil
}
guard hourlyDispo else { return }
self.state = .hourlyDispo
}
static func online() -> AVOnlineStatus {
return AVOnlineStatus(state: .online, lastConnection: .zero)
}
}
public class AVAvatarModel: ObservableObject {
enum Source {
case profile
case other
}
@Published var onlineStatus: AVOnlineStatus
@Published var avatarString: String?
var isNewUser: Bool
var isPro: Bool
var sizing: AVAvatarSizing
var bordered: Bool
var source: Source
init(avatarString: String?, isNew: Bool = false, isPro: Bool = false, onlineStatus: AVOnlineStatus, sizing: AVAvatarSizing, bordered: Bool = false, source: Source = .other) {
self.avatarString = avatarString
self.isNewUser = isNew
self.isPro = isPro
self.onlineStatus = onlineStatus
self.sizing = sizing
self.bordered = bordered
self.source = source
}
static func empty() -> AVAvatarModel {
return AVAvatarModel(avatarString: nil,
isNew: false,
isPro: false,
onlineStatus: AVOnlineStatus(state: .offline),
sizing: .M)
}
}
struct AVAvatar: View {
@ObservedObject var model: AVAvatarModel
var body: (some View)? {
ZStack {
Color.clear
self.avatarImage
.overlay(avatarBorder)
.overlay(newUserOverlay, alignment: .topLeading)
.overlay(onlineCircle, alignment: .bottomTrailing)
.frame(width: self.model.sizing.size,
height: self.model.sizing.size)
}
}
private var avatarBorder: (some View)? {
Group {
if self.model.onlineStatus.state == .hourlyDispo && self.model.source != .profile {
Circle()
.stroke(Color.yellow, lineWidth: 3)
} else if self.model.bordered {
if self.model.source == .profile {
Circle()
.stroke(.white, lineWidth: 6)
} else {
Circle()
.stroke(.white, lineWidth: 4)
}
} else {
EmptyView()
}
}
}
private var avatarImage: (some View)? {
if #available(iOS 15.0, *) {
return AsyncImage(
url: URL(string: self.model.avatarString ?? "")
) { image in
image
.resizable()
.clipShape(Circle())
} placeholder: {
Image("AVAsset.Icon.DEFAULT_AVATAR")
.resizable()
.clipShape(Circle())
}
} else {
return Image("")
.resizable()
.clipShape(Circle())
}
}
private var newUserOverlay: (some View)? {
Group {
if self.model.isNewUser && [.online, .offline].contains(self.model.onlineStatus.state) {
Image("")
.resizable()
.clipShape(Circle())
.rotationEffect(Angle(degrees: 30))
} else {
EmptyView()
}
}
}
private var onlineCircle: (some View)? {
Group {
switch self.model.onlineStatus.state {
case .online:
let sizing = self.model.sizing.onlineSize
Circle()
.strokeBorder(Color.white, lineWidth: sizing.borderWidth)
.background(Circle().foregroundColor(Color.white))
.frame(width: sizing.height, height: sizing.height)
.offset(x: sizing.padding.trailing, y: sizing.padding.bottom)
case .recently:
let recentlyText = "\(self.model.onlineStatus.lastConnection ?? 1) min"
let sizing = self.model.sizing.recentlySize
SQText(recentlyText)
.padding(EdgeInsets(top: 0, leading: 3, bottom: 0, trailing: 2))
.foregroundColor(Color.white)
.background(Color.white)
.cornerRadius(sizing.height / 2)
.overlay(
RoundedRectangle(cornerRadius: sizing.height / 2)
.stroke(.white, lineWidth: sizing.borderWidth)
)
.frame(height: sizing.height)
case .hourlyDispo:
let sizing = self.model.sizing.onlineSize
Image("")
.resizable()
.overlay(RoundedRectangle(cornerRadius: sizing.height / 2)
.stroke(.white, lineWidth: sizing.borderWidth))
.background(Circle().foregroundColor(Color(.yellow)))
.frame(width: sizing.height, height: sizing.height)
.offset(x: sizing.padding.trailing, y: sizing.padding.bottom)
default:
EmptyView()
}
}
}
}
struct AVAvatar_Previews: PreviewProvider {
static var previews: some View {
VStack {
AVAvatar(model: AVAvatarModel(
avatarString: "https://preprod.allovoisins.com//uploads/u/avatars/9/b/9/9b93e04d6d_4681618_l.jpg",
isNew: true,
onlineStatus: AVOnlineStatus(state: .offline), sizing: AVAvatarSizing.XS
))
AVAvatar(model: AVAvatarModel(
avatarString: "https://preprod.allovoisins.com//uploads/u/avatars/9/b/9/9b93e04d6d_4681618_l.jpg",
isNew: false,
onlineStatus: AVOnlineStatus(state: .offline, lastConnection: 59), sizing: AVAvatarSizing.XS
))
AVAvatar(model: AVAvatarModel(
avatarString: "https://preprod.allovoisins.com//uploads/u/avatars/9/b/9/9b93e04d6d_4681618_l.jpg",
isNew: true,
onlineStatus: AVOnlineStatus(state: .online), sizing: AVAvatarSizing.S
))
AVAvatar(model: AVAvatarModel(
avatarString: "https://preprod.allovoisins.com//uploads/u/avatars/9/b/9/9b93e04d6d_4681618_l.jpg",
isNew: false,
onlineStatus: AVOnlineStatus(state: .recently, lastConnection: 59), sizing: AVAvatarSizing.S
))
AVAvatar(model: AVAvatarModel(
avatarString: nil,
isNew: true,
onlineStatus: AVOnlineStatus(state: .online), sizing: AVAvatarSizing.M
))
AVAvatar(model: AVAvatarModel(
avatarString: "https://preprod.allovoisins.com//uploads/u/avatars/9/b/9/9b93e04d6d_4681618_l.jpg",
isNew: false,
isPro: true,
onlineStatus: AVOnlineStatus(state: .recently,
lastConnection: 59), sizing: AVAvatarSizing.M, bordered: true
))
AVAvatar(model: AVAvatarModel(
avatarString: "https://preprod.allovoisins.com//uploads/u/avatars/9/b/9/9b93e04d6d_4681618_l.jpg",
isNew: true,
onlineStatus: AVOnlineStatus(state: .hourlyDispo), sizing: AVAvatarSizing.L, bordered: true
))
AVAvatar(model: AVAvatarModel(
avatarString: "https://preprod.allovoisins.com//uploads/u/avatars/9/b/9/9b93e04d6d_4681618_l.jpg",
isNew: false,
onlineStatus: AVOnlineStatus(state: .recently, lastConnection: 59), sizing: AVAvatarSizing.L
))
AVAvatar(model: AVAvatarModel(
avatarString: "https://preprod.allovoisins.com//uploads/u/avatars/9/b/9/9b93e04d6d_4681618_l.jpg",
isNew: true,
onlineStatus: AVOnlineStatus(state: .online), sizing: AVAvatarSizing.XL
))
AVAvatar(model: AVAvatarModel(
avatarString: "https://preprod.allovoisins.com//uploads/u/avatars/9/b/9/9b93e04d6d_4681618_l.jpg",
isNew: false,
isPro: true,
onlineStatus: AVOnlineStatus(state: .recently,
lastConnection: 59), sizing: AVAvatarSizing.XL
))
}
}
}
enum AVAvatarSizing {
case XXS
case XS
case S
case M
case L
case XL
case XXL
var size: CGFloat {
switch self {
case .XXS:
return 26
case .XS:
return 32
case .S:
return 40
case .M:
return 48
case .L:
return 64
case .XL:
return 90
case .XXL:
return 160
}
}
struct OnlineStateSize {
var height: CGFloat
var fontSize: CGFloat? = 0
var borderWidth: CGFloat
var padding: EdgeSpace
struct EdgeSpace {
var bottom: CGFloat
var trailing: CGFloat
}
}
var onlineSize: OnlineStateSize {
switch self {
case .XXS, .XS:
return OnlineStateSize(height: .zero,
borderWidth: 0,
padding: OnlineStateSize.EdgeSpace(bottom: 0,
trailing: 0))
case .S, .M:
return OnlineStateSize(height: 14,
borderWidth: 2,
padding: OnlineStateSize.EdgeSpace(bottom: 0,
trailing: 0))
case .L:
return OnlineStateSize(height: 18,
borderWidth: 3,
padding: OnlineStateSize.EdgeSpace(bottom: 0,
trailing: 0))
case .XL:
return OnlineStateSize(height: 18,
borderWidth: 3,
padding: OnlineStateSize.EdgeSpace(bottom: -3,
trailing: -3))
case .XXL:
return OnlineStateSize(height: 30,
borderWidth: 4,
padding: OnlineStateSize.EdgeSpace(bottom: -8,
trailing: -8))
}
}
var recentlySize: OnlineStateSize {
switch self {
case .XXS, .XS:
return OnlineStateSize(height: .zero,
borderWidth: 0,
padding: OnlineStateSize.EdgeSpace(bottom: 0,
trailing: 0))
case .S:
return OnlineStateSize(height: 13,
fontSize: 9,
borderWidth: 2,
padding: OnlineStateSize.EdgeSpace(bottom: 2,
trailing: 2))
case .M:
return OnlineStateSize(height: 17,
fontSize: 11,
borderWidth: 2,
padding: OnlineStateSize.EdgeSpace(bottom: 2,
trailing: 2))
case .L, .XL:
return OnlineStateSize(height: 17,
fontSize: 11,
borderWidth: 3,
padding: OnlineStateSize.EdgeSpace(bottom: -1,
trailing: 3))
case .XXL:
return OnlineStateSize(height: 30,
fontSize: 18,
borderWidth: 4,
padding: OnlineStateSize.EdgeSpace(bottom: -3,
trailing: 0))
}
}
}

View File

@@ -40,8 +40,13 @@ struct SQPicker: View {
}
}
.frame(maxWidth: .infinity)
.frame(height: 51)
.fixedSize(horizontal: false, vertical: true)
.tint(.sqNeutral(100))
.background {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white)
}
}
func triggerPickerMenu() {
@@ -114,5 +119,8 @@ struct SQPickerPreview: View {
}
#Preview {
SQPickerPreview()
ZStack {
Color.black
SQPickerPreview()
}
}

View File

@@ -135,6 +135,10 @@ struct SQTextField: View {
}
}
.padding(16)
.background {
RoundedRectangle(cornerRadius: 8)
.fill(Color.white)
}
.foregroundStyle(Color.sqNeutral())
.background(isDisabled ? Color.sqNeutral(10) : .clear)
.overlay(

View File

@@ -0,0 +1,34 @@
//
// SQSearchBarButton.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 08/01/2025.
//
import SwiftUI
struct SQSearchBarButton: View {
var placeholder: String
var action: () -> Void = {}
var body: some View {
HStack(spacing: 16) {
SQIcon(.magnifying_glass, type: .solid)
SQText(placeholder, textColor: .sqNeutral(50))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(Color.sqNeutral(10))
.cornerRadius(8)
.onTapGesture {
action()
}
}
}
#Preview {
SQSearchBarButton(placeholder: "Rechercher") {
print("search")
}
}

View File

@@ -52,8 +52,10 @@ struct BoosterSubscriptionSelectionScreen: View {
VStack(spacing: 48) {
ZStack {
ElipseShape()
.fill(Color.sqPurple(60))
Rectangle()
.fill(backgroundGradient)
VStack(alignment: .trailing) {
BoosterLockedToPremierView()
VStack(spacing: 16) {

View File

@@ -0,0 +1,92 @@
//
// CurrentDebugUser.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 15/01/2025.
//
import SwiftUI
struct CurrentDebugUser: View {
var body: some View {
VStack(spacing: 8) {
SQImage("avatar-1", height: 64)
VStack {
SQText("Harbin Leduc")
SQText("Torphy LLC")
SQText("Auto-entrepreneur", size: 12, font: .demiBold)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background {
RoundedRectangle(cornerRadius: 8)
.fill(Color.sqPurple(20))
}
}
HStack {
SQText("UserID:", font: .demiBold)
Spacer()
SQText("103135")
Button {} label: {
SQIcon(.files)
.padding(8)
.background {
Circle()
.fill(Color.sqNeutral(20))
}
}
}
HStack {
SQText("Email:", font: .demiBold)
Spacer()
SQText("test@test.com")
Button {} label: {
SQIcon(.files)
.padding(8)
.background {
Circle()
.fill(Color.sqNeutral(20))
}
}
}
HStack {
SQText("Téléphone:", font: .demiBold)
Spacer()
SQText("0612345678")
Button {} label: {
SQIcon(.files)
.padding(8)
.background {
Circle()
.fill(Color.sqNeutral(20))
}
}
}
// HStack {
// SQText("Auth Token:", font: .demiBold)
// Spacer()
// SQText("fpoFZ4G4ZGhrhreF4Z6ZZG53")
// }
// HStack {
// SQText("Airship ID:", font: .demiBold)
// Spacer()
// SQText("34HZRH657653H636")
// }
// HStack {
// SQText("Firebase Token:", font: .demiBold)
// Spacer()
// SQText("rzogjiéG245G24gégéregezgrz")
// }
}
.padding()
.background {
RoundedRectangle(cornerRadius: 10)
.fill(Color.sqNeutral(10))
}
.padding(.horizontal)
}
}
#Preview {
CurrentDebugUser()
}

View File

@@ -0,0 +1,58 @@
//
// ConfigPrestationSearchView.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 20/01/2025.
//
import SwiftUI
struct ConfigPrestationSearchView: View {
@State var showSearchView: Bool = false
@State var showSuggested: Bool = false
@State var showAllCategories: Bool = false
@State var selectedSearchType: PickerOption = .init(text: "Prestations")
@State var searchTypes: [PickerOption] = [
PickerOption(text: "Prestations"),
PickerOption(text: "Prestation Catégories"),
PickerOption(text: "Prestations + Prestation Catégories")
]
var body: some View {
VStack {
Spacer()
VStack(spacing: 16) {
SQText("Configuration de la recherche des prestations", size: 18, font: .bold)
.multilineTextAlignment(.center)
Divider()
SQText("Types à rechercher :", font: .demiBold)
SQPicker(selection: $selectedSearchType, options: searchTypes)
Toggle(isOn: $showSuggested) {
SQText("Afficher une suggestion", font: .demiBold)
}
Toggle(isOn: $showAllCategories) {
SQText("Afficher \"Toutes les catégories\"", font: .demiBold)
}
}
.padding()
.background {
RoundedRectangle(cornerRadius: 10)
.fill(Color.sqNeutral(10))
}
.padding(.horizontal)
Spacer()
SQFooter {
SQButton("Afficher la vue", color: .sqNeutral(100), textColor: .white) {
showSearchView.toggle()
}
}
.sheet(isPresented: $showSearchView) {}
}
.ignoresSafeArea(.container, edges: .bottom)
}
}
#Preview {
ConfigPrestationSearchView()
}

View File

@@ -0,0 +1,100 @@
//
// DebugLandView.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 15/01/2025.
//
import SwiftUI
enum DebugEnvironment {
case allo1
case allo2
case preprod
case hotfix
case prod
}
struct DebugLandView: View {
@State var currentEnv: PickerOption = .init(text: "Preprod")
@State var userId: String = ""
var pickerOptions: [PickerOption] {
[
PickerOption(text: "Allo1"),
PickerOption(text: "Allo2"),
PickerOption(text: "Allo3"),
PickerOption(text: "Allo4"),
PickerOption(text: "Allo5"),
PickerOption(text: "Allo6"),
PickerOption(text: "Allo7"),
PickerOption(text: "Allo8"),
PickerOption(text: "Allo9"),
PickerOption(text: "Allo10"),
PickerOption(text: "Preprod"),
PickerOption(text: "Hotfix"),
PickerOption(text: "Prod"),
]
}
var body: some View {
ScrollView {
VStack(spacing: 16) {
CurrentDebugUser()
VStack {
SQText("Environnement :", font: .demiBold)
SQPicker(selection: $currentEnv, options: pickerOptions)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 10)
.fill(Color.sqNeutral(10))
}
.padding(.horizontal)
VStack(alignment: .leading, spacing: 8) {
SQText("User ID :", font: .demiBold)
HStack(spacing: 8) {
VStack {
SQTextField("Visiter le profil :", placeholder: "UserID", text: $userId)
SQButton("Visiter le profil", color: .sqNeutral(100), textColor: .white) {
}
}
VStack {
SQTextField("Se connecter sur :", placeholder: "UserID", text: $userId)
.keyboardType(.numberPad)
SQButton("Se connecter", color: .sqNeutral(100), textColor: .white) {
}
}
}
}
.padding()
.background {
RoundedRectangle(cornerRadius: 10)
.fill(Color.sqNeutral(10))
}
.padding(.horizontal)
}
}
.toolbar {
ToolbarItem(placement: .principal) {
SQText("🦄 Debug Land 🦄", font: .bold)
}
ToolbarItem(placement: .primaryAction) {
Button {} label: {
SQIcon(.user_group)
}
}
}
}
}
#Preview {
NavigationStack {
DebugLandView()
}
}

View File

@@ -19,6 +19,7 @@ struct CategorySelectorView: View {
Rectangle()
.fill(Color.sqNeutral(100))
.frame(height: 2)
.padding(.top, 1)
}
VStack {
SQText("Objets", font: .demiBold)
@@ -26,6 +27,7 @@ struct CategorySelectorView: View {
Rectangle()
.fill(Color.sqNeutral(20))
.frame(height: 1)
.padding(.top, 1)
}
}
}

View File

@@ -0,0 +1,33 @@
//
// SubCategoryCell.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 08/01/2025.
//
import SwiftUI
struct SubCategoryCell: View {
var text: String
@Binding var isChecked: Bool
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
if isChecked {
SQImage("checked_neutral", height: 20)
} else {
SQImage("checkbox_unchecked", height: 20)
}
SQText("Bricolage - Travaux")
}
.padding()
Divider()
.padding(.horizontal)
}
}
}
#Preview {
SubCategoryCell(text: "Bricolage - Petits travaux", isChecked: .constant(true))
}

View File

@@ -55,6 +55,7 @@ struct PrestationSearchView: View {
}
}
.padding(.vertical)
}
}
.padding()

View File

@@ -0,0 +1,53 @@
//
// FAQ.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 22/01/2025.
//
import Foundation
// Modèle pour une question fréquente
struct FAQItem: Identifiable {
let id = UUID()
let question: String
let answer: String
}
// Extension de Pricing pour gérer les FAQs selon l'abonnement
extension Pricing {
var faqItems: [FAQItem] {
switch self {
case .standard:
return []
case .premier:
return [
FAQItem(
question: "Comment résilier mon abonnement Premier ?",
answer: "L'abonnement Premier est sans engagement. Vous pouvez le résilier à tout moment, depuis le menu Abonnement > Gérer mes abonnements > Demander la résiliation."
)
] + commonFAQItems
case .premierPro:
return [
FAQItem(
question: "Comment résilier mon abonnement Premier ?",
answer: "Pendant votre essai gratuit de 14 jours, la résiliation est possible à tout moment. Passé ce délai, la résiliation sera effective à la fin de votre engagement de 12 mois. Vous pouvez résilier votre abonnement depuis le menu Abonnement > Gérer mes abonnements > Demander la résiliation."
)
] + commonFAQItems
}
}
// Questions communes à tous les abonnements
private var commonFAQItems: [FAQItem] {
[
FAQItem(
question: "Comment me faire payer mes prestations ?",
answer: "Paiement en ligne ou paiement en direct lors de la prestation, vous choisissez le mode de paiement qui vous convient. En optant pour le paiement en ligne, vous bénéficiez d'un paiement réalisé en toute sécurité, sans commission."
),
FAQItem(
question: "Comment contacter le Service Clients ?",
answer: "Pour nous contacter, il suffit de vous rendre sur le lien \"Contactez-nous\" présent au bas de tous nos articles de notre aide en ligne.\nNotre Service Clients est disponible pour répondre à toutes vos questions, du lundi au vendredi, de 9h à 18h."
)
]
}
}

View File

@@ -0,0 +1,83 @@
//
// Pricing.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 21/01/2025.
//
import SwiftUI
enum Pricing: String, CaseIterable, Identifiable {
case standard
case premier
case premierPro
var id: String { self.rawValue }
var headerTitle: String {
switch self {
case .standard, .premier:
return "Abonnements"
case .premierPro:
return "Abonnement Premier"
}
}
var footerButtonColor: Color {
switch self {
case .standard:
return .sqNeutral(100)
case .premier, .premierPro:
return .sqOrange(50)
}
}
var footerTextColor: Color {
switch self {
case .standard:
return .sqNeutral(100)
case .premier, .premierPro:
return .sqOrange(70)
}
}
var footerBackgroundColor: Color {
switch self {
case .standard:
return .sqNeutral(15)
case .premier, .premierPro:
return .sqOrange(20)
}
}
var footerPrimaryText: String {
switch self {
case .standard:
return "Gratuit"
case .premier:
return "À partir de 9,99 € / mois"
case .premierPro:
return "Essai gratuit* de 14 jours"
}
}
var footerSecondaryText: String {
switch self {
case .standard:
return ""
case .premier:
return "Sans engagement"
case .premierPro:
return "À partir de 29,99 € / mois"
}
}
var footerTertiaryText: String {
switch self {
case .standard, .premier:
return ""
case .premierPro:
return "* Offre dessai valable une seule fois par utilisateur."
}
}
}

View File

@@ -0,0 +1,110 @@
//
// PricingFeature.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 22/01/2025.
//
// Représente une fonctionnalité individuelle
struct PricingFeature {
let description: String
let value: FeatureValue
enum FeatureValue {
case boolean(Bool)
case text(String)
}
}
// Représente une section de fonctionnalités
struct PricingFeatureSection {
let title: String
let subtitle: String?
let features: [PricingFeature]
}
// Extension de Pricing pour gérer les sections de fonctionnalités
extension Pricing {
var featureSections: [PricingFeatureSection] {
switch self {
case .standard:
// MARK: - Standard
return [
PricingFeatureSection(
title: "Proposer mes services",
subtitle: "Répondez aux demandes dans votre périmètre d'intervention",
features: [
PricingFeature(description: "Nombre de réponses", value: .text("jusqu'à 4 / mois")),
PricingFeature(description: "Demandes ayant déjà reçu 3 réponses", value: .boolean(false)),
PricingFeature(description: "Demandes réservées aux abonnés Premier", value: .boolean(false))
]
),
PricingFeatureSection(
title: "Gérer la visibilité de mon profil",
subtitle: nil,
features: [
PricingFeature(description: "Possibilité d'afficher mon numéro de téléphone", value: .boolean(false)),
PricingFeature(description: "Affichage des photos de mes réalisations", value: .text("3")),
PricingFeature(description: "Collecte d'avis auprès de mes anciens contacts", value: .boolean(false)),
PricingFeature(description: "Référencement prioritaire sur Google", value: .boolean(false))
]
)
]
case .premier:
// MARK: - Premier
return [
PricingFeatureSection(
title: "Proposer mes services",
subtitle: "Répondez aux demandes dans votre périmètre d'intervention",
features: [
PricingFeature(description: "Nombre de réponses", value: .text("illimité")),
PricingFeature(description: "Demandes ayant déjà reçu 3 réponses", value: .boolean(true)),
PricingFeature(description: "Demandes réservées aux abonnés Premier", value: .boolean(true))
]
),
PricingFeatureSection(
title: "Gérer la visibilité de mon profil",
subtitle: nil,
features: [
PricingFeature(description: "Possibilité d'afficher mon numéro de téléphone", value: .boolean(true)),
PricingFeature(description: "Affichage des photos de mes réalisations", value: .text("50")),
PricingFeature(description: "Collecte d'avis auprès de mes anciens contacts", value: .boolean(true)),
PricingFeature(description: "Référencement prioritaire sur Google", value: .boolean(true))
]
)
]
case .premierPro:
// MARK: - Premier Pro
return [
PricingFeatureSection(
title: "Développer ma clientèle",
subtitle: "Répondez aux demandes dans votre périmètre d'intervention",
features: [
PricingFeature(description: "Nombre de réponses", value: .text("illimité")),
PricingFeature(description: "Demandes ayant déjà reçu 3 réponses", value: .boolean(true)),
PricingFeature(description: "Demandes réservées aux Pros", value: .boolean(true))
]
),
PricingFeatureSection(
title: "Augmenter ma visibilité",
subtitle: nil,
features: [
PricingFeature(description: "Référencement prioritaire sur Google", value: .boolean(true)),
PricingFeature(description: "Affichage de mon numéro de téléphone\nsur mon profil", value: .boolean(true)),
PricingFeature(description: "Collecte d'avis auprès de mes anciens\nclients (hors AlloVoisins)", value: .boolean(true)),
PricingFeature(description: "Cartes de visite et prospectus\npersonnalisés", value: .boolean(true))
]
),
PricingFeatureSection(
title: "Gagner du temps",
subtitle: nil,
features: [
PricingFeature(description: "Logiciel de facturation intégré", value: .boolean(true)),
PricingFeature(description: "Signature électronique des documents", value: .boolean(true)),
PricingFeature(description: "Relance client automatisée", value: .boolean(true))
]
)
]
}
}
}

View File

@@ -0,0 +1,35 @@
//
// ReassuranceIndicator.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 22/01/2025.
//
import Foundation
// Modèle pour un indicateur de réassurance
struct ReassuranceIndicator: Identifiable {
let id = UUID()
let icon: SQIconName
let text: String
}
// Extension de Pricing pour gérer les indicateurs selon l'abonnement
extension Pricing {
var reassuranceIndicators: [ReassuranceIndicator] {
switch self {
case .standard, .premier:
return [
ReassuranceIndicator(icon: .lock_keyhole_open, text: "Sans engagement"),
ReassuranceIndicator(icon: .euro_sign, text: "Pas de commission sur vos prestations"),
ReassuranceIndicator(icon: .comments, text: "Assistance prioritaire")
]
case .premierPro:
return [
ReassuranceIndicator(icon: .users, text: "4 millions de membres"),
ReassuranceIndicator(icon: .euro_sign, text: "Pas de commission sur vos prestations"),
ReassuranceIndicator(icon: .comments, text: "Assistance prioritaire")
]
}
}
}

View File

@@ -0,0 +1,37 @@
//
// FAQRow.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 22/01/2025.
//
import SwiftUI
struct FAQRow: View {
let item: FAQItem
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button(action: {
withAnimation {
isExpanded.toggle()
}
}) {
HStack {
SQText(item.question, size: 13, font: .demiBold)
Spacer()
SQIcon(isExpanded ? .chevron_up : .chevron_down)
}
.padding(.vertical, 16)
}
if isExpanded {
SQText(item.answer, size: 14)
.padding(.bottom, 16)
}
Divider()
}
}
}

View File

@@ -0,0 +1,24 @@
//
// FAQSection.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 22/01/2025.
//
import SwiftUI
struct FAQSection: View {
let items: [FAQItem]
var body: some View {
VStack(alignment: .leading, spacing: 0) {
SQText("Questions fréquentes", size: 16, font: .demiBold)
.padding(.vertical, 8)
ForEach(items) { item in
FAQRow(item: item)
}
}
.padding(.horizontal)
}
}

View File

@@ -0,0 +1,29 @@
//
// FeatureRow.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 22/01/2025.
//
import SwiftUI
struct FeatureRow: View {
let feature: PricingFeature
var body: some View {
HStack(alignment: .top) {
SQText("", size: 14)
SQText("\(feature.description)", size: 14)
Spacer()
switch feature.value {
case .boolean(let isIncluded):
SQIcon(isIncluded ? .check : .xmark,
size: .l,
type: .solid,
color: isIncluded ? .sqSemanticGreen : .sqSemanticRed)
case .text(let text):
SQText(text, size: 14, font: .demiBold)
}
}
}
}

View File

@@ -0,0 +1,25 @@
//
// FeatureSection.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 22/01/2025.
//
import SwiftUI
struct FeatureSection: View {
let section: PricingFeatureSection
var body: some View {
VStack(alignment: .leading, spacing: 8) {
SQText(section.title, size: 18, font: .bold)
if let subtitle = section.subtitle {
SQText(subtitle, size: 13, font: .demiBold)
}
ForEach(section.features, id: \.description) { feature in
FeatureRow(feature: feature)
}
}
.padding()
}
}

View File

@@ -0,0 +1,36 @@
//
// PricingSubscribeFooter.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 21/01/2025.
//
import SwiftUI
struct PricingSubscribeFooter: View {
var pricing: Pricing
var body: some View {
VStack {
SQText(pricing.footerPrimaryText, size: 18, font: .bold, textColor: pricing.footerTextColor)
if !pricing.footerSecondaryText.isEmpty {
SQText(pricing.footerSecondaryText, size: 13, textColor: pricing.footerTextColor)
}
SQButton("Continuer", color: pricing.footerButtonColor, textColor: .white) {
}
if !pricing.footerTertiaryText.isEmpty {
SQText(pricing.footerTertiaryText, size: 12, textColor: .sqOrange(70))
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.background(pricing.footerBackgroundColor)
.cornerRadius(8)
.shadow(color: Color(red: 0.09, green: 0.14, blue: 0.2).opacity(0.1), radius: 8, x: 0, y: -4)
}
}
#Preview {
PricingSubscribeFooter(pricing: .standard)
}

View File

@@ -0,0 +1,37 @@
//
// PricingSubscribeHeader.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 21/01/2025.
//
import SwiftUI
struct PricingSubscribeHeader: View {
var pricing: Pricing
var body: some View {
HStack {
Button {
} label: {
SQIcon(.xmark, size: .l)
}
Spacer()
SQText(pricing.headerTitle, size: 18, font: .bold)
Spacer()
if pricing == .premierPro {
Button {
} label: {
SQIcon(.user_group, size: .l)
}
}
}
.padding()
}
}
#Preview {
PricingSubscribeHeader(pricing: .premier)
}

View File

@@ -0,0 +1,27 @@
//
// ReassuranceIndicator.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 22/01/2025.
//
import SwiftUI
struct ReassuranceIndicatorSection: View {
let indicator: ReassuranceIndicator
var body: some View {
VStack(spacing: 8) {
ZStack {
Circle()
.fill(Color.sqNeutral(15))
.frame(width: 32, height: 32)
SQIcon(indicator.icon, size: .s, type: .solid)
}
SQText(indicator.text, size: 14)
.multilineTextAlignment(.center)
}
}
}

View File

@@ -0,0 +1,24 @@
//
// ReassuranceIndicatorsRow.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 22/01/2025.
//
import SwiftUI
struct ReassuranceIndicatorsRow: View {
let indicators: [ReassuranceIndicator]
var body: some View {
HStack(spacing: 8) {
ForEach(indicators) { indicator in
ReassuranceIndicatorSection(indicator: indicator)
if indicator.id != indicators.last?.id {
Spacer()
}
}
}
.padding(.horizontal)
}
}

View File

@@ -0,0 +1,44 @@
//
// SwitchAndInfo.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 21/01/2025.
//
import SwiftUI
struct SwitchAndInfo: View {
@Binding var pricing: Pricing
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if pricing == .premierPro {
HStack {
SQIcon(.chart_line_up, size: .l)
SQText("Labonnement conçu pour augmenter votre chiffre daffaires et développer votre entreprise.", font: .demiBold)
}
.padding()
.background {
RoundedRectangle(cornerRadius: 8)
.foregroundStyle( Color.sqNeutral(10))
}
} else {
Picker("", selection: $pricing) {
SQText("Standard").tag(Pricing.standard)
SQText("Premier").tag(Pricing.premier)
}
.pickerStyle(.segmented)
if pricing == .standard {
SQText("Idéal pour proposer vos services ponctuellement, sans objectif de complément de revenus.", font: .demiBold)
} else {
SQText("Idéal pour vous générer un complément de revenus régulier.", font: .demiBold)
}
}
}
.padding()
}
}
#Preview {
SwitchAndInfo(pricing: .constant(.premier))
}

View File

@@ -0,0 +1,47 @@
//
// SubscriptionsPricingView.swift
// AlloVoisinsSwiftUI
//
// Created by Victor on 21/01/2025.
//
import SwiftUI
struct SubscriptionsPricingView: View {
@State var pricing: Pricing
var body: some View {
VStack(alignment: .leading, spacing: 0) {
PricingSubscribeHeader(pricing: pricing)
Divider()
ScrollView {
SwitchAndInfo(pricing: $pricing)
Divider()
VStack(spacing: 0) {
ForEach(pricing.featureSections, id: \.title) { section in
FeatureSection(section: section)
}
}
if pricing != .standard {
Divider()
ReassuranceIndicatorsRow(indicators: pricing.reassuranceIndicators)
.padding()
Divider()
FAQSection(items: pricing.faqItems)
}
}
PricingSubscribeFooter(pricing: pricing)
}
.ignoresSafeArea(.container, edges: .bottom)
}
}
#Preview {
Color.white
.sheet(isPresented: .constant(true)) {
SubscriptionsPricingView(pricing: .premierPro)
}
}

View File

@@ -0,0 +1,185 @@
%PDF-1.7
1 0 obj
<< /Type /XObject
/Length 2 0 R
/Group << /Type /Group
/S /Transparency
>>
/Subtype /Form
/Resources << >>
/BBox [ 0.000000 0.000000 300.000000 300.000000 ]
>>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.799255 0.843708 0.898039 scn
0.000000 150.000000 m
0.000000 232.842712 67.157288 300.000000 150.000000 300.000000 c
232.842712 300.000000 300.000000 232.842712 300.000000 150.000000 c
300.000000 67.157288 232.842712 0.000000 150.000000 0.000000 c
67.157288 0.000000 0.000000 67.157288 0.000000 150.000000 c
h
f
n
Q
q
1.000000 0.000000 -0.000000 1.000000 -16.041626 -56.863281 cm
0.467200 0.544662 0.640000 scn
148.459488 268.987030 m
162.539291 275.160522 177.380173 271.215851 186.499435 258.885834 c
249.168854 174.124954 l
257.207672 163.255920 259.575836 147.484070 255.444305 132.350998 c
229.972214 43.073334 l
223.329834 18.725861 202.148956 3.297180 182.670288 8.614502 c
25.247545 51.567444 l
5.768888 56.884735 -4.638077 80.929825 2.004305 105.277298 c
27.564735 194.867538 l
31.648695 209.830734 41.525627 222.113205 53.794498 227.491669 c
148.459488 268.987030 l
h
178.071045 156.238922 m
179.628311 155.843689 181.186005 155.448364 182.744797 155.053558 c
185.099365 154.458969 187.620422 155.801025 188.198013 158.162369 c
188.408676 159.032166 188.513992 159.959717 188.490219 160.945007 c
188.395081 164.791107 186.132248 168.423157 182.670059 170.101593 c
176.452377 173.122070 169.405670 169.632721 167.768005 163.309753 c
167.764603 163.292770 167.761200 163.279175 167.754410 163.262192 c
167.166626 160.934814 168.753311 158.600662 171.084091 158.009476 c
173.413559 157.421005 175.741806 156.830093 178.071045 156.238922 c
h
127.326241 105.638535 m
112.848907 109.389526 102.557457 121.807877 99.061287 137.311295 c
98.208481 141.089462 101.412453 144.843842 104.697968 143.991028 c
165.733154 128.178452 l
169.018661 127.325638 169.997192 122.490814 167.418381 119.602814 c
156.834732 107.748474 141.806976 101.887558 127.326241 105.638535 c
h
114.798615 187.724869 m
108.625107 190.592484 101.697327 187.109894 100.073257 180.844635 c
100.039284 180.712128 100.005302 180.583008 99.978127 180.450500 c
99.478668 178.116333 101.000809 175.816116 103.314606 175.221527 c
104.788635 174.843964 106.262665 174.465424 107.736877 174.086838 c
110.136459 173.470612 112.536537 172.854248 114.937920 172.241791 c
117.248314 171.647202 119.691216 172.934906 120.374138 175.221527 c
120.737686 176.437897 120.880386 177.779953 120.730896 179.254532 c
120.360550 182.873032 118.101120 186.189133 114.798615 187.724869 c
h
f*
n
Q
endstream
endobj
2 0 obj
2432
endobj
3 0 obj
<< /Type /XObject
/Length 4 0 R
/Group << /Type /Group
/S /Transparency
>>
/Subtype /Form
/Resources << >>
/BBox [ 0.000000 0.000000 300.000000 300.000000 ]
>>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 0.000000 0.000000 cm
0.000000 0.000000 0.000000 scn
0.000000 150.000000 m
0.000000 232.842712 67.157288 300.000000 150.000000 300.000000 c
232.842712 300.000000 300.000000 232.842712 300.000000 150.000000 c
300.000000 67.157288 232.842712 0.000000 150.000000 0.000000 c
67.157288 0.000000 0.000000 67.157288 0.000000 150.000000 c
h
f
n
Q
endstream
endobj
4 0 obj
405
endobj
5 0 obj
<< /XObject << /X1 1 0 R >>
/ExtGState << /E1 << /SMask << /Type /Mask
/G 3 0 R
/S /Alpha
>>
/Type /ExtGState
>> >>
>>
endobj
6 0 obj
<< /Length 7 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
/E1 gs
/X1 Do
Q
endstream
endobj
7 0 obj
46
endobj
8 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 300.000000 300.000000 ]
/Resources 5 0 R
/Contents 6 0 R
/Parent 9 0 R
>>
endobj
9 0 obj
<< /Kids [ 8 0 R ]
/Count 1
/Type /Pages
>>
endobj
10 0 obj
<< /Pages 9 0 R
/Type /Catalog
>>
endobj
xref
0 11
0000000000 65535 f
0000000010 00000 n
0000002692 00000 n
0000002715 00000 n
0000003370 00000 n
0000003392 00000 n
0000003690 00000 n
0000003792 00000 n
0000003813 00000 n
0000003988 00000 n
0000004062 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 10 0 R
/Size 11
>>
startxref
4122
%%EOF

View File

@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "Avatar0.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}