Learning Swift - Day Five

In Progress

The Goal

Today is day 5 of learning Swift. Most of the morning I spent refactoring and doing some experiements on how I should get data from an API, put it into a store and then render it to the Swift UI. I’ve be trying to think of the best structure to allow for scalable code. I will finally finish of with leacture five of the Stanford course.


What I’ve experiemented with

// Folder structure
// Root
    // Modules
        // SessionDetails
            // API
            // Components
            // Screens
            // Stores
    // Services
        // SupabaseService.swift
import Foundation
import Supabase

struct SessionDetailsQueries {
    private let client: SupabaseClient

    init(client: SupabaseClient = SupabaseService.client) {
        self.client = client
    }

    func getSession(by sessionId: UUID) async throws
        -> BookableSession?
    {
        return try await SupabaseService.client
            .from("bookable_sessions")
            .select()
            .eq("session_id", value: sessionId)
            .single()
            .execute()
            .value

    }
}
import Foundation
import SwiftUI

@MainActor
@Observable final class SessionDetailStore {
    private let queries: SessionDetailsQueries
    private let sessionId: UUID

    private(set) var session: BookableSession?
    private(set) var isLoading = false
    private(set) var isRefetching = false
    private(set) var error: Error?

    /// Active booking id for the current member, when this session is booked —
    /// needed to present the cancel sheet. `nil` until loaded or when not booked.
    private(set) var bookingId: String?

    init(
        sessionId: UUID,
        queries: SessionDetailsQueries? = nil
    ) {
        self.sessionId = sessionId
        self.queries = queries ?? SessionDetailsQueries()
    }

    func load(memberId: UUID?) async {
        let hasSession = session != nil
        isRefetching = hasSession
        isLoading = !hasSession
        error = nil

        await performFetch(memberId: memberId)

        isLoading = false
        isRefetching = false
    }

    // A seperate refresh func because putting it all together can sometimes cause the refresh to cancel which throws an error.
    func refresh(memberId: UUID?) async {
        await performFetch(memberId: memberId)
    }

    private func performFetch(memberId: UUID?) async {
        do {
            let session = try await queries.getSession(by: sessionId)
            self.session = session
            await loadBookingId(for: session, memberId: memberId)
        } catch {
            self.error = error
        }
    }

    /// Looks up the member's active booking for this session so it can be
    /// cancelled. Resets to `nil` when the session isn't booked or has no member.
    private func loadBookingId(
        for session: BookableSession?,
        memberId: UUID?
    ) async {
        guard let session, session.isBooked ?? false, let memberId else {
            bookingId = nil
            return
        }
        let bookings =
            (try? await BookingQueries.getActiveBookings(
                memberId: memberId,
                classSessionIds: [sessionId]
            )) ?? [:]

        bookingId = bookings[sessionId]?.uuidString
    }
}
import MooseCore
import MooseUI
import SwiftData
import SwiftUI

struct SessionDetailsScreen: View {
    let sessionId: UUID

    @Environment(CreditStore.self) private var creditStore
    @Environment(\.modelContext) private var modelContext

    @Query private var members: [StoredMember]
    private var member: StoredMember? { members.current }

    @State private var selectedAction: BookingAction<BookableSession>?
    @State private var store: SessionDetailStore

    init(sessionId: UUID) {
        self.sessionId = sessionId
        _store = State(initialValue: SessionDetailStore(sessionId: sessionId))
    }

    var body: some View {
        Group {
            if store.isLoading {
                LoadingScreen(message: "Loading session...")
            } else if let error = store.error {
                VStack {
                    Text(error.localizedDescription)
                        .foregroundStyle(.label2)
                        .padding()
                }
            } else if let session = store.session {
                content(for: session)
            }
        }
        .navigationTitle(store.session?.name ?? "No name found")
        .navigationSubtitle(store.session?.studioName ?? "No studio found")
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            if let credits = creditStore.summary {
                ToolbarItem(placement: .topBarTrailing) {
                    CreditChip(credits: credits)
                }
            }
        }
        .bookingActionSheet(
            $selectedAction,
            creditStore: creditStore,
            onDismiss: { Task { await store.refresh(memberId: member?.id, ) } }
        )
        .appBackground()
        .task(id: member?.id) {
            await store.refresh(memberId: member?.id)
        }
    }

    @ViewBuilder
    private func content(for session: BookableSession) -> some View {
        let info = session.startDateTimeLabels
        VStack(alignment: .leading) {
            ScrollView {
                VStack(alignment: .leading, spacing: Spacing.lg) {
                    VStack(alignment: .leading) {

                        Text(info.time)
                            .font(.largeTitle)
                            .foregroundStyle(.ink)
                            .bold()

                        Text(info.date).foregroundStyle(.label2)

                    }

                    WidgetRow(
                        durationMinutes: session.durationLabel,
                        remainingSpots: session.spotsLabel,
                        creditCosts: session.creditCostLabel
                    ).appShadow(.sm)

                    InstructorCard(
                        instructorName: session.instructorLabel
                    )

                    VStack(alignment: .leading, spacing: Spacing.sm) {
                        Text("Description")
                            .font(.title3)
                            .bold()
                            .foregroundStyle(.ink)

                        Text(session.classDescription ?? "No description found")
                            .font(.default)
                            .foregroundStyle(.label2)
                    }
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding()
            }
            .refreshable {
                print("🟧 .refreshable fired, cancelled=\(Task.isCancelled)")
                await store.refresh(memberId: member?.id)
            }

            footer(for: session)
                .padding(.horizontal, Spacing.md)
                .padding(
                    .bottom,
                    Spacing.md
                )
        }
    }

    @ViewBuilder
    private func footer(for session: BookableSession) -> some View {

        if session.isBooked ?? false {
            VStack {
                AddToCalendarButton(
                    session: session,
                    kind: .primary,
                    accessibilityIdentifier:
                        "session-details.add-to-calendar-button"
                )
                AppButton(
                    title: "Cancel",
                    kind: .danger,
                    accessibilityIdentifier: "session-details.cancel-button"
                ) {
                    guard let bookingId = store.bookingId else { return }
                    selectedAction = .cancel(
                        session: session,
                        bookingId: bookingId
                    )
                }
                .disabled(store.bookingId == nil)
            }
        } else {
            AppButton(
                title: "Book class",
                accessibilityIdentifier: "session-details.book-button"
            ) {
                selectedAction = .book(session)
            }
        }
    }
}

// MARK: - Session details display values

extension BookableSession {
    /// Spots remaining as a bare count, e.g. `4`, falling back to an em dash.
    var spotsLabel: String {
        guard let spotsRemaining else { return "—" }
        return String(spotsRemaining)
    }

    /// Credit cost formatted to two decimals, e.g. `2.00`, or an em dash.
    var creditCostLabel: String {
        guard let creditCost else { return "—" }
        return creditCost.formatted(.number.precision(.fractionLength(2)))
    }

    /// Instructor name with a placeholder fallback.
    var instructorLabel: String { instructorName ?? "-" }

    /// Formatted start time and date, e.g. `(time: "6:00 PM", date: "Thu 4 Jun")`,
    /// falling back to em dashes when the stored ISO-8601 string can't be parsed.
    var startDateTimeLabels: (time: String, date: String) {
        guard let startTime,
            let parsed = ISO8601DateFormatter().date(from: startTime)
        else {
            return (time: "—", date: "—")
        }
        return (
            time: DateService.formatTime(parsed),
            date: DateService.formatShortDate(parsed)
        )
    }
}

#Preview {
    NavigationStack {
        SessionDetailsScreen(sessionId: UUID())
    }
}

Leacture

Layout

  1. Container Views “offer” some or all of the spaces offered to them
  2. Views then choose what size they want to be
  3. Container views then position the views inside them
HStack {
    Text("Important").layoutPriority(100)
    Image(systeName: "arrow.up")
    Text("Unimportant")
}

Data flow

MatchMarkers(matches: [.exact, ...])
    .environment(\.colorScheme, .dark)
struct ViewA: View {
    @State private var myData: Int = 42 // the source of truth for myData

    var body: some View {
        ViewB(foo: $myData) // $myData means "a binding to myData"
    }
}

struct ViewB: View {
    @Binding var foo: Int
    // can get and set the value of myData by using foo
}