Google AI Studio c Gemini 2.5 для создания iOS приложения на основе макетов из Figma

Мне случайно попалось задание с собеседования 2-х годичной давности на позицию «iOS разработчик» , в котором требовалось создать iOS приложения для бронирования номера в Отеле на основе макетов экранов из Figma, в которых использовалась «карусель» из фото. 

Я решила попробовать использовать ИИ для разработки такого iOS приложения по макетам из Figma. Прямо скажем, что текст на прототипах экранов не очень отчетливо виден, так что ИИ предстоит его сначала прочитать, а уже потом создать iOS приложение.

Для меня приоритетными ИИ при создании iOS приложений являются ChatGPT 4.o-mini, Claude 3.5 (3.7) Sonnet и Grok 3, ну, может быть, DeepSeek R1, но Gemini 1.5 всегда давал хотя и корректный, но не самый лаконичный и интересный код.

Однако Gemini всегда прекрасно читал текст на изображениях ( в том числе на русском), кроме того, появилась более продвинутая Gemini 2.5 Pro Experimental —  мультимодальная, рассуждающая модель AI, которую компания называет самой интеллектуальной моделью на сегодняшний день.

Так что у нас есть все шансы на успех.

Забегая вперед, сразу скажу, что Gemini 2.5 Experimental превзошел ожидания: точное воспроизведение Figma-макетов в SwiftUI (стили, функционал) + нешаблонный код, а изобретательные решения (FlexibleGridView и др.). Стартовый код — база для масштабируемого приложения с реализацией загрузки данных с сервера (REST API). Код находится на Github.

Вот как выглядит протокол взаимодействия с Gemini 2.5 Experimental в Google AI Studio:

Отель и категории номеров

Ответ Gemini 2.5 Experimental

Хорошо, давайте создадим SwiftUI структуру для этого приложения с информацией об отеле на основе ваших снимков экрана.

Мы создадим:

  1. Модели данных: для представления информации об отеле и номере.
  2. HotelDetailView: главный экран, на котором отображаются сведения об отеле, карусель изображений, описание и ссылки на другие разделы.
  3. AmenitiesView: View-заполнитель для сведений об удобствах.
  4. RoomSelectionView: экран для отображения различных категорий номеров с их каруселями фото и дополнительными сведениями об удобствах.
  5. RoomCardView: повторно используемое View для отображения одной карточки категории номера.
  6. Вспомогательные View: например, ImageCarouselView и SectionRow.
import SwiftUI

// MARK: - Data Models

struct Hotel: Identifiable {
    let id = UUID()
    let name: String
    let rating: Double
    let ratingName: String
    let address: String
    let minimalPrice: Int
    let priceForIt: String
    let imageUrls: [String] // Use image names from Assets or URLs
    let description: String
    let peculiarities: [String] // Like "3-я линия", "Платный Wi-Fi в фойе"
}

struct Room: Identifiable {
    let id = UUID()
    let name: String
    let price: Int
    let pricePer: String
    let peculiarities: [String] // Like "Все включено", "Кондиционер"
    let imageUrls: [String]
}


// MARK: - Content View (Root)

struct ContentView: View {
    // --- MOCK DATA ---
    // In a real app, this would come from a ViewModel/API
    let sampleHotel = Hotel(
        name: "Steigenberger Makadi", // Updated name based on screenshot 4
        rating: 5.0,
        ratingName: "Превосходно",
        address: "Madinat Makadi, Safaga Road, Makadi Bay, Египет",
        minimalPrice: 134673,
        priceForIt: "за тур с перелётом",
        imageUrls: ["hotel_placeholder_1", "hotel_placeholder_2", "hotel_placeholder_3", "hotel_placeholder_4", "hotel_placeholder_5", "hotel_placeholder_6", "hotel_placeholder_7"], // Add your image names
        description: "Отель VIP-класса с собственными гольф полями. Высокий уровень сервиса. Рекомендуем для респектабельного отдыха.",
        peculiarities: ["3-я линия", "Платный Wi-Fi в фойе", "30 км до аэропорта", "1 км до пляжа"]
    )

     let sampleRooms = [
        Room(name: "Стандартный с видом на бассейн или сад", price: 186600, pricePer: "за 7 ночей с перелётом", peculiarities: ["Все включено", "Кондиционер"], imageUrls: ["room1_image1", "room1_image2","room1_image3","room1_image4"]),
        Room(name: "Люкс с видом на море", price: 250000, pricePer: "за 7 ночей с перелётом", peculiarities: ["Все включено", "Кондиционер", "Мини-бар"], imageUrls: ["suite1_image1", "suite1_image2", "suite1_image3"])
        // Add more rooms
    ]
    // --- ---

    var body: some View {
        NavigationView {
            HotelDetailView(hotel: sampleHotel, rooms: sampleRooms)
        }
        // Optional: Use .accentColor for global tint if needed
        // .accentColor(.blue)
    }
}

// MARK: - Hotel Detail View

struct HotelDetailView: View {
    let hotel: Hotel
    let rooms: [Room] // Pass rooms data for navigation

    @State private var selectedImageIndex = 0

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 8) {
                // --- Section 1: Image Carousel, Basic Info ---
                VStack(alignment: .leading, spacing: 16) {
                    ImageCarouselView(imageUrls: hotel.imageUrls, 
                                      selectedIndex: $selectedImageIndex)
                        .frame(height: 250) // Adjust height as needed
                        .clipShape(RoundedRectangle(cornerRadius: 15)) // Rounded corners

                    HStack(spacing: 4) {
                        Image(systemName: "star.fill")
                            .foregroundColor(.orange)
                        Text("\(hotel.rating, specifier: "%.1f") \(hotel.ratingName)")
                            .foregroundColor(.orange)
                            .font(.system(size: 16, weight: .medium))
                    }

                    Text(hotel.name)
                        .font(.system(size: 22, weight: .medium))

                    Button {
                        // Action for address tap if needed (e.g., open map)
                        print("Address tapped")
                    } label: {
                        Text(hotel.address)
                            .font(.system(size: 14, weight: .medium))
                            .foregroundColor(.blue) // Link-like appearance
                    }

                    HStack(alignment: .bottom, spacing: 8) {
                         Text("от \(hotel.minimalPrice) ₽")
                            .font(.system(size: 30, weight: .semibold))
                         Text(hotel.priceForIt.lowercased())
                            .font(.system(size: 16, weight: .regular))
                            .foregroundColor(.secondary)
                            .padding(.bottom, 4) // Align baseline better
                    }

                }
                .padding(.horizontal)
                .padding(.vertical, 8) // Add some vertical padding
                .background(Color.white) // White background for this section
                .cornerRadius(15) // Rounded corners for the section


                 // --- Section 2: About the Hotel ---
                 VStack(alignment: .leading, spacing: 16) {
                    Text("Об отеле")
                        .font(.system(size: 22, weight: .medium))

                    // Peculiarities (Tags)
                    FlexibleGridView(data: hotel.peculiarities) { item in
                        Text(item)
                            .font(.system(size: 16, weight: .medium))
                            .foregroundColor(.gray)
                            .padding(.horizontal, 10)
                            .padding(.vertical, 5)
                            .background(Color(UIColor.systemGray6)) // Tag background
                            .cornerRadius(5)
                    }


                    Text(hotel.description)
                         .font(.system(size: 16, weight: .regular))

                    // --- Detail Buttons ---
                    VStack(spacing: 0) {
                         NavigationLink(destination: AmenitiesView()) { // Navigate to Amenities
                             SectionRow(iconName: "face.smiling",
                                        title: "Удобства",
                                        subtitle: "Самое необходимое")
                         }
                         Divider().padding(.leading, 45) // Indent divider
                         SectionRow(iconName: "checkmark.circle",
                                    title: "Что включено", 
                                    subtitle: "Основные услуги")
                         Divider().padding(.leading, 45)
                         SectionRow(iconName: "xmark.circle", 
                                    title: "Что не включено", 
                                    subtitle: "Дополнительные услуги")
                    }
                    .background(Color(UIColor.systemGray6))
                    .cornerRadius(15)
                    .padding(.top) // Add space before buttons


                 }
                 .padding(.horizontal)
                 .padding(.vertical, 8)
                 .background(Color.white)
                 .cornerRadius(15)
                 .padding(.top, 8) // Space between sections


            } // End Main VStack
        } // End ScrollView
        .background(Color(UIColor.systemGray6).ignoresSafeArea()) // Background for the whole screen
        .navigationBarTitleDisplayMode(.inline) // Keep title small
       .toolbar { // Hide the default back button text
            ToolbarItem(placement: .principal) {
                 Text(hotel.name) // Or just "Отель"
                    .font(.system(size: 18, weight: .medium))
            }
        }
        .safeAreaInset(edge: .bottom) {
             // --- Bottom Button ---
             NavigationLink(destination: 
                  RoomSelectionView(hotelName: hotel.name, rooms: rooms)) {
                  Text("К выбору номера")
                     .font(.system(size: 16, weight: .medium))
                     .foregroundColor(.white)
                     .frame(maxWidth: .infinity)
                     .padding()
                     .background(Color.blue)
                     .cornerRadius(15)
             }
             .padding(.horizontal)
             .padding(.top, 8) // Add space above button
             .background( // Add a white background behind the button area
                 Color.white
                    .ignoresSafeArea(edges: .bottom) // Extend white to screen bottom edge
             )
         }
    }
}

// MARK: - Image Carousel View

struct ImageCarouselView: View {
    let imageUrls: [String]
    @Binding var selectedIndex: Int

    var body: some View {
        TabView(selection: $selectedIndex) {
            ForEach(0..<imageUrls.count, id: \.self) { index in
                Image(imageUrls[index]) // Assumes images are in Assets
                    .resizable()
                    .scaledToFill()
                    // .aspectRatio(contentMode: .fill) // Alternative scaling
                    .tag(index)
                    // Add error handling/placeholder if needed
                    .overlay{
                        VStack {
                            Spacer()
                            HStack {
                                Spacer()
                                Text("\(index + 1) / \(imageUrls.count)")
                                    .bold()
                                    .foregroundStyle(Color.white)
                                    .padding(.horizontal, 45)
                            }
                            .padding()
                        }
                    }
            }
        }
        .tabViewStyle(.page(indexDisplayMode: .never)) // Hide default dots
        .overlay(
            // Custom Pagination Dots
            HStack(spacing: 8) {
                ForEach(0..<imageUrls.count, id: \.self) { index in
                    Circle()
                        .fill(selectedIndex == index ? Color.black : Color.gray.opacity(0.7))
                        .frame(width: 7, height: 7)
                }
            }
            .padding(.horizontal, 12)
            .padding(.vertical, 8)
            .background(Color.white.opacity(0.9))
            .cornerRadius(5)
            .padding(.bottom, 10) // Position dots from bottom
            , alignment: .bottom // Align dots at the bottom
        )
    }
}

// MARK: - Section Row Helper View

struct SectionRow: View {
    let iconName: String
    let title: String
    let subtitle: String

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: iconName)
                .resizable()
                .scaledToFit()
                .frame(width: 24, height: 24)
                .foregroundColor(.primary.opacity(0.9))

            VStack(alignment: .leading) {
                Text(title)
                    .font(.system(size: 16, weight: .medium))
                    .foregroundColor(.primary)
                Text(subtitle)
                    .font(.system(size: 14, weight: .regular))
                    .foregroundColor(.secondary)
            }
            Spacer()
            Image(systemName: "chevron.right")
                .foregroundColor(.secondary)
        }
        .padding(.horizontal)
        .padding(.vertical, 10)
    }
}


// MARK: - Amenities View (Placeholder)

struct AmenitiesView: View {
    var body: some View {
        ScrollView { // Make it scrollable in case content grows
            VStack {
                 Text("Details about Удобства")
                     .padding()
                 // Add actual amenity details here (Lists, Grids, etc.)
                 Spacer()
            }
        }
        .navigationTitle("Удобства") // Standard navigation title
        .navigationBarTitleDisplayMode(.inline)
    }
}

// MARK: - Room Selection View

struct RoomSelectionView: View {
    let hotelName: String
    let rooms: [Room]

    var body: some View {
        ScrollView {
            VStack(spacing: 8) {
                ForEach(rooms) { room in
                    RoomCardView(room: room)
                }
            }
            .padding(.vertical) // Add padding top/bottom inside scrollview
        }
        .navigationTitle(hotelName) // Use hotel name as title
        .navigationBarTitleDisplayMode(.inline)
        .background(Color(UIColor.systemGray6).ignoresSafeArea()) // Match background
    }
}

// MARK: - Room Card View

struct RoomCardView: View {
    let room: Room
    @State private var selectedImageIndex = 0 // Each card manages its own carousel state

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            ImageCarouselView(imageUrls: room.imageUrls, 
                              selectedIndex: $selectedImageIndex)
                 .frame(height: 200) // Adjust height
                 .clipShape(RoundedRectangle(cornerRadius: 15))

            Text(room.name)
                 .font(.system(size: 22, weight: .medium))

             // Peculiarities (Tags)
            FlexibleGridView(data: room.peculiarities) { item in
                Text(item)
                    .font(.system(size: 16, weight: .medium))
                    .foregroundColor(.gray)
                    .padding(.horizontal, 10)
                    .padding(.vertical, 5)
                    .background(Color(UIColor.systemGray6))
                    .cornerRadius(5)
            }

             // Room Price
             HStack(alignment: .bottom, spacing: 8) {
                  Text("\(room.price) ₽")
                     .font(.system(size: 30, weight: .semibold))
                  Text(room.pricePer.lowercased())
                     .font(.system(size: 16, weight: .regular))
                     .foregroundColor(.secondary)
                     .padding(.bottom, 4)
             }

             // Choose Room Button
             Button {
                 // Action for selecting THIS specific room
                 print("Selected room: \(room.name)")
             } label: {
                   Text("Выбрать номер")
                      .font(.system(size: 16, weight: .medium))
                      .foregroundColor(.white)
                      .frame(maxWidth: .infinity)
                      .padding()
                      .background(Color.blue)
                      .cornerRadius(15)
             }

        }
        .padding()
        .background(Color.white)
        .cornerRadius(15)
        .padding(.horizontal) // Add horizontal space between cards and screen edge
    }
}


// MARK: - Flexible Grid View (Helper for Tags)

struct FlexibleGridView<Data: Collection, Content: View>: View where Data.Element: Hashable {
    let data: Data
    let spacing: CGFloat
    let alignment: HorizontalAlignment
    let content: (Data.Element) -> Content
    @State private var availableWidth: CGFloat = 0

    init(data: Data, spacing: CGFloat = 8, alignment: HorizontalAlignment = .leading, @ViewBuilder content: @escaping (Data.Element) -> Content) {
        self.data = data
        self.spacing = spacing
        self.alignment = alignment
        self.content = content
    }

    var body: some View {
    ZStack(alignment: Alignment(horizontal: alignment, vertical: .center)) {
            Color.clear
                .frame(height: 1)
                .readSize { size in
                    availableWidth = size.width
                }

            _FlexibleGrid(
                availableWidth: availableWidth,
                data: data,
                spacing: spacing,
                alignment: alignment,
                content: content
            )
        }
    }
}

struct _FlexibleGrid<Data: Collection, Content: View>: View where Data.Element: Hashable {
    let availableWidth: CGFloat
    let data: Data
    let spacing: CGFloat
    let alignment: HorizontalAlignment
    let content: (Data.Element) -> Content
    @State var elementsSize: [Data.Element: CGSize] = [:]

    var body: some View {
        VStack(alignment: alignment, spacing: spacing) {
            ForEach(computeRows(), id: \.self) { rowElements in
                HStack(spacing: spacing) {
                    ForEach(rowElements, id: \.self) { element in
                        content(element)
                            .fixedSize()
                            .readSize { size in
                                elementsSize[element] = size
                            }
                    }
                }
            }
        }
    }

    func computeRows() -> [[Data.Element]] {
        var rows: [[Data.Element]] = [[]]
        var currentRow = 0
        var remainingWidth = availableWidth

        for element in data {
            let elementSize = elementsSize[element, default: CGSize(width: availableWidth, height: 1)]

            if remainingWidth - (elementSize.width + spacing) >= 0 {
                rows[currentRow].append(element)
            } else {
                currentRow += 1
                rows.append([element])
                remainingWidth = availableWidth
            }
            remainingWidth -= (elementSize.width + spacing)
        }
        return rows
    }
}

// Helper to read view size
struct SizePreferenceKey: PreferenceKey {
    static var defaultValue: CGSize = .zero
    static func reduce(value: inout CGSize, nextValue: () -> CGSize) {}
}
extension View {
    func readSize(onChange: @escaping (CGSize) -> Void) -> some View {
        background(
            GeometryReader { geometryProxy in
                Color.clear
                    .preference(key: SizePreferenceKey.self, value: geometryProxy.size)
            }
        )
        .onPreferenceChange(SizePreferenceKey.self, perform: onChange)
    }
}

#Preview {
    ContentView()
}

Объяснение:

1.Модели данных (Hotel, Room): простые структуры для хранения данных об отелях и номерах. Identifiable используется для ForEach. Для простоты показана статическая модель. В реальном приложении данные могут приходить из API или базы данных.

2. HotelApp & ContentView: стандартная точка входа в приложение SwiftUI. ContentView настраивает NavigationView и инициализирует начальное View (HotelDetailView) с помощью тестовых данных.

3. HotelDetailView:

  • Использует ScrollView и VStacks для вертикальной структуры содержимого.
  • Включает ImageCarouselView.
  • Отображает рейтинг, имя, адрес (в виде кнопки), цену.
  • Добавляет раздел «Об отеле» с тегами (используя FlexibleGridView) и описанием.
  • Использует NavigationLink, оборачивающий пользовательские SectionRow для «Удобства», «Что включено», «Что не включено».
  • Большая синяя кнопка «К выбору номера» размещена внизу с помощью .safeAreaInset(edge: .bottom), чтобы она оставалась видимой и слегка перекрывала прокручиваемое содержимое. Это NavigationLink для RoomSelectionView.
  • .navigationBarTitleDisplayMode(.inline) сохраняет компактность панели навигации.

4. ImageCarouselView:

  • Использует TabView с .tabViewStyle(.page).
  • .page (indexDisplayMode: .never) скрывает точки страниц по умолчанию.
  • Добавляется наложение внизу для создания пользовательских точек для страниц, привязанных к переменной @State selectedIndex.

5. SectionRow: Многоразовое View для выбираемых строк, показывающих детали (удобства и т. д.). Включает иконку, заголовок, подзаголовок и шеврон.

6. AmenitiesView: Простое View-заполнитель. Вы можете заменить Text фактическим списком удобств.

7. RoomSelectionView:

  • Принимает hotelName (для заголовка) и массив объектов Room.
  • Использует ScrollView и VStack для размещения множества RoomCardViews.
  • Устанавливает заголовок навигации .navigationTitle.

8. RoomCardView:

  • Отображает сведения об одной комнате, включая ее собственный ImageCarouselView.
  • Показывает имя, теги (FlexibleGridView), цену и кнопку «Выбрать номер», характерную для этой комнаты.

9. FlexibleGridView и другие: вспомогательное View для динамического размещения элементов тегов (особенностей peculiarities), перенося их на следующую строку, если они не помещаются по горизонтали. Включает помощника модификатора readSize.

10. Заполнители: не забудьте заменить «hotel1», «room1» и т. д. фактическими именами ваших изображений в вашем файле Assets.xcassets.

Эта структура обеспечивает прочную основу, соответствующую дизайну, показанному на ваших снимках экрана. Теперь вы можете заполнить ее реальными данными (потенциально полученными из API с помощью ViewModel) и дополнительно уточнить детали UI.

Вот какой результат мы получили:

Экран с отелем:

Переход к выбору категории номера:

Дополнительная информация об удобствах:

А вот как работают «карусели» фотографий как для самого отеля, так и для различных категорий номеров:

  • Итоги работы с Gemini 2.5 + мвкеты Figma:
    1. Точность воспроизведения макетов Figma в SwiftUI:
      — Экран отеля и категорий номеров воссозданы на SwiftUI с сохранением стилей текста, функционала и локализации (русский язык).
    2. Автономная генерация данных:
      — Gemini 2.5 самостоятельно сформировала структуру тестовых данных без внешних API.
    3. Архитектура кода:
      — Лаконичный SwiftUI код с TabView для «карусели» фотографий и повторно используемыми компонентами: SectionView, ImageCarouselView.
      — Инновационный FlexibleGridView для адаптивного отображения элементов удобств.
    4. Ресурсы:
      — Тестовые изображения интегрированы через Assets.xcassets.
    Что улучшить:
    — Реализовать загрузку данных с сервера (REST API).
    — Оптимизировать передачу данных между View.
    — Усилить обработку ошибок и читаемость кода.Резюме: Стартовый код готов к доработке, демонстрируя высокий потенциал Gemini 2.5 в преобразовании Figma-макетов в рабочий iOS-код.

Вдохновившись таким результатом, давайте создадим экран для бронирования номера отеля.

Бронирование номера отеля (часть 1)

Прототип экрана из Figma:

Делаем запрос Gemini 2.5 Experimental 03-25

Ответ Gemini 2.5 Experimental

Хорошо, давайте создадим SwiftUI View для экрана c информацией о бронировании, включив маскировку номера телефона и проверку адреса электронной почты по запросу.

Мы разобьем его на компоненты:

  • BookingInfoView: основное View контейнера.
  • Вспомогательные Views: для отдельных блоков (отель, сведения о бронировании, информация о покупателе).
  • Логика форматирования номера телефона: использование @State и .onReceive, это ключ к тому, чтобы шаблон работал в режиме реального ввода.
  • Логика проверки адреса электронной почты: использование @State, .onChange и простого регулярного выражения.
import SwiftUI
import Combine // Needed for keyboard observers if we wanted more advanced focus handling

// MARK: - Data Models (Simple representations for the example)

struct HotelBookingInfo {
    let ratingValue: Int = 5
    let ratingName: String = "Превосходно"
    let hotelName: String = "Steigenberger Makadi"
    let hotelAddress: String = "Madinat Makadi, Safaga Road, Makadi Bay, Египет"
}

struct BookingDetails {
    let departureCity: String = "Санкт-Петербург"
    let countryCity: String = "Египет, Хургада"
    let dates: String = "19.09.2023 - 27.09.2023"
    let nights: String = "7 ночей"
    let hotelName: String = "Steigenberger Makadi"
    let roomType: String = "Стандартный с видом на бассейн или сад"
    let mealPlan: String = "Все включено"
}

// MARK: - Main Booking Info View

struct BookingInfoView: View {
    // --- State Variables ---
    @State private var phoneNumber: String = "" // Stores the RAW digits (max 10)
  //  @State private var displayedPhoneNumber: String = "+7 " // What the user sees/edits
    @State private var email: String = ""
    @State private var isEmailValid: Bool = true // Assume valid initially or until typed

    // --- Data ---
    let hotelInfo = HotelBookingInfo()
    let bookingDetails = BookingDetails()

    // --- Environment ---
    @Environment(\.presentationMode) var presentationMode

    var body: some View {
        NavigationView {
            ScrollView {
                VStack(spacing: 8) { // Spacing between blocks
                    HotelInfoBlock(info: hotelInfo)
                    BookingDetailsBlock(details: bookingDetails)
                    BuyerInfoBlock(
                        phoneNumber: $phoneNumber,
                        email: $email,
                        isEmailValid: $isEmailValid
                    )

                    Spacer() // Pushes content up if screen is tall
                }
                .padding(.horizontal) // Padding for the whole content stack
                .padding(.top)      // Padding from the navigation bar
            }
            .background(Color(UIColor.systemGray6)) // Background for the whole screen
            .navigationTitle("Бронирование")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .navigationBarLeading) {
                    Button {
                        presentationMode.wrappedValue.dismiss()
                    } label: {
                        Image(systemName: "chevron.left")
                    }
                    .tint(.primary) // Ensure back button has default color
                }
            }
        }
    }
}

// MARK: - Block Views

struct HotelInfoBlock: View {
    let info: HotelBookingInfo

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(spacing: 4) {
                Image(systemName: "star.fill")
                Text("\(info.ratingValue) \(info.ratingName)")
            }
            .font(.system(size: 16, weight: .medium))
            .foregroundColor(.orange)
            .padding(.horizontal, 10)
            .padding(.vertical, 5)
            .background(Color.orange.opacity(0.15))
            .cornerRadius(5)

            Text(info.hotelName)
                .font(.system(size: 22, weight: .medium))

            Text(info.hotelAddress)
                .font(.system(size: 14, weight: .regular))
                .foregroundColor(.blue) // Link-like appearance
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading) // Take full width
        .background(Color.white)
        .cornerRadius(12)
    }
}

struct BookingDetailsBlock: View {
    let details: BookingDetails

    var body: some View {
        VStack(alignment: .leading, spacing: 16) { // Increased spacing between rows
            BookingDetailRow(label: "Вылет из", value: details.departureCity)
            BookingDetailRow(label: "Страна, город", value: details.countryCity)
            BookingDetailRow(label: "Даты", value: details.dates)
            BookingDetailRow(label: "Кол-во ночей", value: details.nights)
            BookingDetailRow(label: "Отель", value: details.hotelName)
            BookingDetailRow(label: "Номер", value: details.roomType)
            BookingDetailRow(label: "Питание", value: details.mealPlan)
        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Color.white)
        .cornerRadius(12)
    }
}

// Helper for Booking Details rows
struct BookingDetailRow: View {
    let label: String
    let value: String

    var body: some View {
        HStack(alignment: .top) { // Align top for potentially multi-line values
            Text(label)
                .font(.system(size: 16))
                .foregroundColor(.secondary)
                .frame(width: 110, alignment: .leading) // Fixed width for labels

            Text(value)
                .font(.system(size: 16))
                .foregroundColor(.primary)
                .frame(maxWidth: .infinity, alignment: .leading) // Take remaining space
        }
    }
}

struct BuyerInfoBlock: View {
    @Binding var phoneNumber: String // Raw digits
  //  @Binding var displayedPhoneNumber: String // Formatted for display/edit
    @Binding var email: String
    @Binding var isEmailValid: Bool
    
    @State var isValid: Bool = false
    let phoneRegex = try! NSRegularExpression(pattern: #"^\+7 \(\d{3}\) \d{3}-\d{2}-\d{2}$"#)

    var body: some View {
        VStack(alignment: .leading, spacing: 20) { // Space between title and fields
            Text("Информация о покупателе")
                .font(.system(size: 22, weight: .medium))

            VStack(alignment: .leading, spacing: 8) { // Space within the fields group
                Text("Номер телефона")
                    .font(.system(size: 12))
                    .foregroundColor(.secondary)
                    .padding(.leading, 12) // Indent label slightly

                TextField("", text: $phoneNumber, prompt: Text("+7 (***) *** - ** - **").foregroundColor(.gray))
                    .keyboardType(.numberPad)
                    .onReceive(Just(phoneNumber)) { newValue in
                        if !phoneNumber.isEmpty {
                            phoneNumber = formatPhoneNumber(newValue)
                            validatePhoneNumber(newValue)
                        }
                    }
                    .onAppear{
                        UITextField.appearance().clearButtonMode = .whileEditing
                    }
                    .padding()
                    .background(Color(.systemGray6))
                    .cornerRadius(8)
                
                if !isValid {
                    Text("Invalid format. Example: +7 (901) 555-66-77")
                        .foregroundColor(.red)
                        .font(.caption)
                }

                Text("Почта")
                    .font(.system(size: 12))
                    .foregroundColor(.secondary)
                    .padding(.leading, 12)

                TextField("example@mail.ru", text: $email)
                    .keyboardType(.emailAddress)
                    .autocapitalization(.none)
                    .disableAutocorrection(true)
                    .padding(.horizontal, 12)
                    .padding(.vertical, 10)
                    .background(isEmailValid ? Color(UIColor.systemGray6) : Color.red.opacity(0.15)) // Visual validation feedback
                    .cornerRadius(10)
                    .overlay( // Optional: Add border for invalid state
                        RoundedRectangle(cornerRadius: 10)
                            .stroke(isEmailValid ? Color.clear : Color.red, lineWidth: 1)
                    )
                    .onChange(of: email) { _, newValue in
                        // Validate only if email is not empty
                        if !newValue.isEmpty {
                             isEmailValid = isValidEmail(newValue)
                        } else {
                            isEmailValid = true // Consider empty as valid or neutral
                        }
                    }
            }

            Text("Эти данные никому не передаются. После оплаты мы вышли чек на указанный вами номер и почту")
                .font(.system(size: 14))
                .foregroundColor(.secondary)

        }
        .padding()
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(Color.white)
        .cornerRadius(12)
    }

    // MARK: Phone Masking Logic
    // Formats the input string to the template: +7 (XXX) XXX-XX-XX.
    private  func formatPhoneNumber(_ number: String) -> String {
            // Remove any non-digit characters.
            let digitsOnly = number.filter { $0.isNumber }
            var cleanedNumber = digitsOnly
            
            // If the first digit is 7 or 8, remove it.
            if let first = cleanedNumber.first, first == "7" || first == "8" {
                cleanedNumber.removeFirst()
            }
            
            // Limit to 10 digits.
            if cleanedNumber.count > 10 {
                cleanedNumber = String(cleanedNumber.prefix(10))
            }
            
            // Start with the fixed +7 prefix.
            var formatted = "+7 "
            
            // Create the area code part.
            if !cleanedNumber.isEmpty {
                formatted += "("
                let areaCodeEnd = min(3, cleanedNumber.count)
                let areaCode = cleanedNumber.prefix(areaCodeEnd)
                formatted += areaCode
                if areaCode.count == 3 {
                    formatted += ") "
                }
            }
            
            // Next 3 digits.
            if cleanedNumber.count > 3 {
                let startIndex = cleanedNumber.index(cleanedNumber.startIndex, offsetBy: 3)
                let endIndex = cleanedNumber.index(startIndex, offsetBy: min(3, cleanedNumber.count - 3))
                let firstThree = cleanedNumber[startIndex..<endIndex]
                formatted += firstThree
                if firstThree.count == 3 {
                    formatted += "-"
                }
            }
            
            // Next 2 digits.
            if cleanedNumber.count > 6 {
                let startIndex = cleanedNumber.index(cleanedNumber.startIndex, offsetBy: 6)
                let endIndex = cleanedNumber.index(startIndex, offsetBy: min(2, cleanedNumber.count - 6))
                let nextTwo = cleanedNumber[startIndex..<endIndex]
                formatted += nextTwo
                if nextTwo.count == 2 {
                    formatted += "-"
                }
            }
            
            // Last 2 digits.
            if cleanedNumber.count > 8 {
                let startIndex = cleanedNumber.index(cleanedNumber.startIndex, offsetBy: 8)
                let endIndex = cleanedNumber.index(startIndex, offsetBy: min(2, cleanedNumber.count - 8))
                let lastTwo = cleanedNumber[startIndex..<endIndex]
                formatted += lastTwo
            }
            
            return formatted
        }
    
    private func validatePhoneNumber(_ number: String) {
        let range = NSRange(location: 0, length: number.utf16.count)
        // Check if the entire string matches the regular expression
        isValid = phoneRegex.firstMatch(in: number, options: [], range: range) != nil
    }

     // MARK: Email Validation Logic
     private func isValidEmail(_ email: String) -> Bool {
         // Simple regex for basic email format validation
         let emailFormat = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
         let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailFormat)
         return emailPredicate.evaluate(with: email)
     }
}


// MARK: - Preview

#Preview {
        BookingInfoView()
}

Ключевые пояснения:

  1. Управление состоянием: переменные @State (phoneNumber, email, isEmailValid) используются в BookingInfoView и передаются как @Bindings в BuyerInfoBlock, где они фактически используются в TextFields.
  2. Маска для телефонного номера (formatPhoneNumber):
    • Она активируется .onReceive(Just(phoneNumber)).
    • .onReceive: это ключ к тому, чтобы маскировка работала в реальном времени.
    • Модификатор onReceive отслеживает изменения состояния phoneNumber. Всякий раз, когда phoneNumber изменяется, вызывается функция форматирования formatPhoneNumber), и phoneNumber обновляется замаскированной версией.
    • TextField, которое форматирует номер телефона по мере ввода пользователем, следуя шаблону +7 (XXX) XXX-XX-XX.
    • В этом примере пользователь должен вводить цифры без начальных 7 или 8, и код удаляет любые такие цифры, если они присутствуют в начале ввода.
    • formatPhoneNumber извлекает только числовые символы, ограничивает запись 10 цифрами (после удаления начальных 7 или 8), а затем применяет маску.
  3. Проверка электронной почты (isValidEmail):
    • Простая вспомогательная функция isValidEmail(_ email: String) использует NSPredicate с общим регулярным выражением электронной почты.
    • .onChange(of: email) запускает проверку.
    • Цвет фона (и необязательная граница) текстового поля электронной почты изменяется в зависимости от состояния isEmailValid, обеспечивая визуальную обратную связь.
    • Проверка происходит только в том случае, если поле не пустое.
  4. Макет и стили:
    • Использует VStack, HStack, Spacer, .padding, .background, .cornerRadius для имитации блоков макета.
    • BookingDetailRow — помощник для стандартизации строк меток-значений. Метки имеют фиксированную ширину для выравнивания.
    • Цвета фона (Color.white, Color(UIColor.systemGray6)) используются для различения блоков и фона экрана.
    • Шрифты и цвета устанавливаются так, чтобы они соответствовали снимку экрана.
  5. Навигация: стандартный NavigationView со встроенным заголовком и настраиваемой кнопкой «Back» на панели инструментов.

Теперь у вас есть функциональное SwiftUI View, которое повторяет дизайн из Figma и включает заданные поведения полей ввода. Не забудьте заменить данные заполнителя (HotelBookingInfo, BookingDetails) на ваш фактический источник данных.

Вот как функционирует View для бронирования номера:

Бронирование номера отеля (часть 2)

Прототип экрана из Figma:

Делаем запрос Gemini 2.5 Experimental 03-25

Ответ Gemini 2.5 Experimental

Хорошо, давайте создадим SwiftUI View для ввода туристической информации о туристах и отображения окончательной разбивки цен, включая сворачиваемые разделы и возможность динамического добавления туристов.

import SwiftUI

// MARK: - Data Models

struct Tourist: Identifiable {
    let id = UUID() // Necessary for ForEach
    var isExpanded: Bool = true // State for collapsing/expanding

    // Form fields
    var firstName: String = ""
    var lastName: String = ""
    var dateOfBirth: String = "" // Could use Date type later
    var citizenship: String = ""
    var passportNumber: String = ""
    var passportExpiry: String = "" // Could use Date type later
}

struct PricingInfo {
    let tourPrice: Int
    let fuelSurcharge: Int
    let serviceCharge: Int

    var totalPrice: Int {
        tourPrice + fuelSurcharge + serviceCharge
    }
}

// MARK: - Main View

struct TouristInfoView: View {
    // --- State Variables ---
    @State private var tourists: [Tourist] = [Tourist()] // Start with one tourist
    @State private var pricing = PricingInfo(
        tourPrice: 186600,
        fuelSurcharge: 9300,
        serviceCharge: 2136
    )

    var body: some View {
    
        VStack {
            ScrollView {
                VStack(spacing: 8) { // Spacing between blocks

                    // --- Tourist Information Block ---
                    TouristListBlock(tourists: $tourists)

                    // --- Add Tourist Button ---
                    AddTouristButton {
                        addNewTourist()
                    }

                    // --- Price Summary Block ---
                    PriceSummaryBlock(pricing: pricing)
                    PaymentButton(totalAmount: pricing.totalPrice) {
                        // Action for payment
                        print("Proceeding to pay \(formattedPrice(pricing.totalPrice))")
                    }

                }
                .padding(.horizontal) // Padding for the whole content stack
                .padding(.top)
            }
            .background(Color(UIColor.systemGray6).ignoresSafeArea()) // Background for the whole screen
        }
    }

    // --- Helper Functions ---

    private func addNewTourist() {
        withAnimation { // Animate the addition
             tourists.append(Tourist())
        }
    }

     private func formattedPrice(_ price: Int) -> String {
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.groupingSeparator = " " // Use space as separator
        return (formatter.string(from: NSNumber(value: price)) ?? "\(price)") + " ₽"
    }
}

// MARK: - Child Views / Blocks

struct TouristListBlock: View {
    @Binding var tourists: [Tourist]

    var body: some View {
        VStack(spacing: 8) { // Consistent spacing
             ForEach($tourists) { $tourist in // Use $ for bindings
                // Find the index for displaying the correct number
                 if let index = tourists.firstIndex(where: { $0.id == tourist.id }) {
                    TouristEntryView(
                        tourist: $tourist,
                        touristNumber: index + 1 // Pass the 1-based index
                    )
                 }
            }
        }
        // No background/cornerRadius here, apply it to TouristEntryView itself
    }
}

struct TouristEntryView: View {
    @Binding var tourist: Tourist
    let touristNumber: Int

    // Helper to get ordinal string ("Первый", "Второй", etc.)
    private func ordinal(number: Int) -> String {
        // Basic implementation, expand for more numbers if needed
        switch number {
        case 1: return "Первый"
        case 2: return "Второй"
        case 3: return "Третий"
        case 4: return "Четвертый"
        case 5: return "Пятый"
        default: return "\(number)-й"
        }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) { // No spacing for header/content separation
            // --- Header ---
            HStack {
                Text("\(ordinal(number: touristNumber)) турист")
                    .font(.system(size: 22, weight: .medium))
                Spacer()
                Button {
                    withAnimation(.easeInOut(duration: 0.2)) { // Animate expand/collapse
                        tourist.isExpanded.toggle()
                    }
                } label: {
                    Image(systemName: tourist.isExpanded ? "chevron.up" : "chevron.down")
                        .foregroundColor(.blue)
                        .padding(8) // Increase tappable area
                        .background(Color.blue.opacity(0.1))
                        .clipShape(RoundedRectangle(cornerRadius: 6))
                }
            }
            .padding() // Padding for the header itself

            // --- Form Fields (Conditional) ---
            if tourist.isExpanded {
                VStack { // Spacing between fields
                    StylizedTextField(placeholder: "Имя", 
                             text: $tourist.firstName)
                    StylizedTextField(placeholder: "Фамилия", 
                             text: $tourist.lastName)
                    StylizedTextField( placeholder: "Дата рождения", 
                             text: $tourist.dateOfBirth)
                    StylizedTextField( placeholder: "Гражданство", 
                             text: $tourist.citizenship)
                    StylizedTextField(placeholder: "Номер загранпаспорта", 
                             text: $tourist.passportNumber)
                    StylizedTextField(
                        placeholder: "Срок действия загранпаспорта", 
                        text: $tourist.passportExpiry)
                }
                .padding(.horizontal) // Padding for the form fields container
                .padding(.bottom)    // Add padding at the bottom of the form
                // Smooth transition for appearing fields
                .transition(.opacity.combined(with: .move(edge: .top)))
            }
        }
        .background(Color.white)
        .cornerRadius(12)
    }
}

// Reusable TextField with placeholder label
struct StylizedTextField: View {
    let placeholder: String
    @Binding var text: String

    var body: some View {
            TextField(placeholder, text: $text)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(10)
                .overlay (alignment: .top) {
                if !text.isEmpty {
                    Text(placeholder)
                        .font(.system(size: 12))
                        .foregroundColor(.gray)
                        .padding(.leading, 12) // Indent title slightly
                        .padding(.bottom, -2) // Pull textfield up a bit
                        .zIndex(1) // Ensure title is above TextField background
                }
            }
    }
}

struct AddTouristButton: View {
    let action: () -> Void

    var body: some View {
        HStack {
            Text("Добавить туриста")
                .font(.system(size: 22, weight: .medium))
            Spacer()
            Button(action: action) {
                Image(systemName: "plus")
                    .foregroundColor(.white)
                    .padding()
                    .background(Color.blue)
                    .clipShape(RoundedRectangle(cornerRadius: 6))
            }
        }
        .padding()
        .background(Color.white)
        .cornerRadius(12)
    }
}

struct PriceSummaryBlock: View {
    let pricing: PricingInfo

    private func formattedPrice(_ price: Int) -> String {
        // Duplicated formatting logic for self-containment, could be centralized
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.groupingSeparator = " "
        return (formatter.string(from: NSNumber(value: price)) ?? "\(price)") + " ₽"
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 16) {
            PriceRow(label: "Тур", amount: pricing.tourPrice)
            PriceRow(label: "Топливный сбор", amount: pricing.fuelSurcharge)
            PriceRow(label: "Сервисный сбор", amount: pricing.serviceCharge)
            PriceRow(label: "К оплате", amount: pricing.totalPrice,
                                                             isTotal: true)
        }
        .padding()
        .background(Color.white)
        .cornerRadius(12)
    }
}

// Helper for rows in the price summary
struct PriceRow: View {
    let label: String
    let amount: Int
    var isTotal: Bool = false

    private func formattedPrice(_ price: Int) -> String {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
         formatter.groupingSeparator = " "
         return (formatter.string(from: NSNumber(value: price)) ?? "\(price)") + " ₽"
     }

    var body: some View {
        HStack {
            Text(label)
                .font(.system(size: 16))
                .foregroundColor(.secondary)

            Spacer()

            Text(formattedPrice(amount))
            .font(.system(size: 16, weight: isTotal ? .semibold : .regular))
            .foregroundColor(isTotal ? .blue : .primary) // Highlight total price
        }
    }
}


struct PaymentButton: View {
    let totalAmount: Int
    let action: () -> Void

    private func formattedPrice(_ price: Int) -> String {
         let formatter = NumberFormatter()
         formatter.numberStyle = .decimal
         formatter.groupingSeparator = " "
         return (formatter.string(from: NSNumber(value: price)) ?? "\(price)") + " ₽"
     }

    var body: some View {
        Button(action: action) {
            Text("Оплатить \(formattedPrice(totalAmount))")
                .font(.system(size: 16, weight: .medium))
                .foregroundColor(.white)
                .frame(maxWidth: .infinity)
                .padding()
                .background(Color.blue)
                .cornerRadius(15)
        }
        .padding(.horizontal)
        .padding(.vertical, 8) // Add some vertical padding
        .background( // Add a white background behind the button area
            Color.white
              .ignoresSafeArea(edges: .bottom) // Extend white to screen bottom edge
              .shadow(radius: 1) // Optional subtle shadow above button
        )
    }
}


// MARK: - Preview

#Preview {
        TouristInfoView()
}

Объяснение:

  1. Модели данных (Tourist, PricingInfo): определяют структуры для хранения необходимых данных. Tourist является Identifiable и включает флаг isExpanded. PricingInfo вычисляет totalPrice.
  2. TouristInfoView (главное View):
    • Содержит @State переменные var: массив туристов tourists: [Tourist] и информацию о ценах pricing: PricingInfo.
    • Использует ScrollView для размещения контента.
    • Организует размещение основных блоков (TouristListBlock, AddTouristButton, PriceSummaryBlock, PaymentButton) в VStack.
    • Функция addNewTourist() добавляет нового туриста Tourist по умолчанию в массив.
  3. TouristListBlock:
    • Принимает @Binding к массиву туристов.
    • Использует ForEach для итерации по привязке $tourists ($ имеет решающее значение для изменения элементов в цикле, например isExpanded).
    • Создает TouristEntryView для каждого туриста, передавая привязку и номер туриста (индекс + 1).
  4. TouristEntryView:
    • Представляет сворачиваемую карточку для одного туриста.
    • Принимает @Binding для одного туриста.
    • Имеет HStack заголовок (используя помощник порядкового номера) и кнопку развернуть/свернуть. Кнопка переключает состояние Tourist.isExpanded с анимацией.
    • Условно (if Tourist.isExpanded) отображает VStack, содержащий StylizedTextFields для ввода данных. Появление/исчезновение анимируется с помощью .transition.
    • Применяет белый фон и радиус угла здесь.
  5. StylizedTextField: Многоразовое вспомогательное представление для создания определенного стиля текстового поля, показанного на изображении (метка выше при заполнении, заполнитель при пустом).
  6. AddTouristButton: Простое представление для строки «Добавить туриста» с кнопкой «+». При нажатии вызывается замыкание действия.
  7. PriceSummaryBlock и PriceRow: представления для отображения разбивки цен, аналогично предыдущим примерам, с правильным форматированием чисел. В итоговой строке используется немного более жирный текст и синий цвет.
  8. PaymentButton: последняя синяя кнопка внизу, отображающая общую цену и запускающая действие. Она находится во вставке безопасной области.
  9. Вспомогательные функции (ordinal, formattedPrice): небольшие служебные функции для форматирования текста и чисел.
  10. Стиль: использует отступы, фоны, радиусы углов и systemGray6 для соответствия визуальному стилю. Добавлены анимации для развертывания/свертывания и добавления туристов.

Эта версия напрямую реализует запрошенную функциональность, включая возможность добавлять туристов, разворачивать/сворачивать их разделы и вводить их данные с правильной привязкой данных для редактирования.

Вот как выглядит View для ввода туристов:

Вот как функционирует View для ввода туристов:

Объединяем всё вместе :

Заключение

Gemini 2.5 Experimental воспроизводит в SwiftUI с поразительной точностью стили текста и функциональные возможности макетов, подготовленных дизайнерами в Figma. Особенно это касается разработки русскоязычных UI. 

Выдаёт полноценный изобретательный SwiftUI код, демонстрируя высокий потенциал Gemini 2.5 в преобразовании Figma-макетов в рабочий iOS-код.

Код находится на Github.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *