📱 iOS Development with SwiftUI — A FinSightAI Deep Dive

A complete breakdown of every layer of a real-world SwiftUI app. Based on the open-source FinSightAI project — an AI-powered financial education app for Indian stock market beginners, built with Swift, SwiftUI, and Google Gemini.


What is FinSightAI?

FinSightAI is a financial education chatbot app. A user types in a stock name (e.g., "Reliance", "Infosys"), and the app sends that query to Google Gemini AI with a crafted system prompt. The AI returns a beginner-friendly stock overview — fundamentals, technicals, key risks — and the app renders it inside a chat bubble with full markdown support.

Tech stack:

  • Language: Swift 5.9
  • UI Framework: SwiftUI
  • Architecture: MVVM (Model-View-ViewModel)
  • AI: Google Gemini API via raw URLSession
  • Backend: Firebase (for analytics/notifications)
  • Navigation: NavigationStack + NavigationPath (modern programmatic navigation)

Project Architecture — MVVM

MVVM is the dominant architecture for SwiftUI apps because SwiftUI's reactive property wrappers (@Published, @StateObject, @ObservedObject) are designed specifically for it.

┌─────────────────────────────────────────────────────────────┐
│                          User Action                        │
│                      (types stock name)                     │
└────────────────────────────┬────────────────────────────────┘
                             │
                             ▼
┌────────────────────────────────────────────────────────────┐
│                    VIEW LAYER (SwiftUI)                    │
│   HomeScreenshows messages, sends input to ViewModel    │
└────────────────────────────┬───────────────────────────────┘
                             │  calls vm.send()
                             ▼
┌────────────────────────────────────────────────────────────┐
│                  VIEWMODEL LAYER                           │
│   ChatViewModelmanages state (@Published messages)      │
│   calls GeminiService (Networking sublayer)                │
└────────────────────────────┬───────────────────────────────┘
                             │  async API call
                             ▼
┌────────────────────────────────────────────────────────────┐
│                  NETWORKING LAYER                          │
│   GeminiServicebuilds URLRequest, decodes response      │
└────────────────────────────┬───────────────────────────────┘
                             │  Decodable JSON
                             ▼
┌────────────────────────────────────────────────────────────┐
│                   MODEL LAYER (Data)                       │
│   ChatMessage, GeminiResponseplain Swift structs        │
└────────────────────────────────────────────────────────────┘

Why MVVM?

Concern Layer Responsibility
What to display View Renders data, forwards user events
How to get data ViewModel Business logic, API calls, state management
What the data is Model Pure data structures, no UI imports
Where to navigate Routes Centralised navigation state
App-wide config Resources Assets, plists, fonts

Folder Structure

FinSightAI/
└── FinSightAI/                         ← Xcode project root
    ├── FinSightAIApp.swift             ← @main entry point
    ├── Views/
    │   ├── elements/
    │   │   └── ChatBubble.swift        ← Reusable chat bubble component
    │   ├── HomeScreen.swift            ← Main chat UI
    │   ├── SplashScreen.swift          ← Launch screen
    │   ├── SettingsScreen.swift        ← Settings (placeholder)
    │   └── AboutScreen.swift           ← About page with gradient
    ├── ViewModels/
    │   ├── Networking/
    │   │   └── GeminiService.swift     ← URLSession API calls
    │   └── ViewModel/
    │       └── AIStudioViewModel.swift ← ChatViewModel (state + logic)
    ├── Model/
    │   └── ChatMessageModel.swift      ← ChatMessage + GeminiResponse structs
    ├── Resources/
    │   ├── Assets.xcassets             ← Images, colors, app icon
    │   └── GoogleService-Info.plist    ← Firebase configuration
    └── Routes/
        └── Route.swift                 ← NavigationPath + push/pop helpers

App Entry Point — FinSightAIApp.swift

Every iOS app needs exactly one entry point — the struct marked @main. This is where the app boots, Firebase is configured, and the root navigation is set up.

import Firebase
import SwiftUI

// UIKit lifecycle hook — needed to configure Firebase before any view loads
class AppDelegate: NSObject, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication
            .LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        FirebaseApp.configure()   // initialise Firebase SDKs
        return true
    }
}

@main
struct FinSightAIApp: App {
    // Bridge UIKit AppDelegate into SwiftUI lifecycle
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate

    // Route is created once here and injected via environmentObject
    @StateObject private var route = Route()

    // Controls whether the splash screen shows
    @State private var showSplash = true

    var body: some Scene {
        WindowGroup {
            if showSplash {
                SplashScreen(showSplash: $showSplash)
            } else {
                // NavigationStack is the modern (iOS 16+) navigation container
                NavigationStack(path: $route.navPath) {
                    HomeScreen()
                        .navigationDestination(for: Route.RouteName.self) { dest in
                            switch dest {
                            case .home:     HomeScreen()
                            case .settings: SettingsScreen()
                            case .about:    AboutScreen()
                            }
                        }
                }
                .environmentObject(route)  // makes `route` available to all child views
            }
        }
    }
}

Key concepts used here

Concept What it does
@main Marks this struct as the application entry point — Xcode replaces the old main.swift
App protocol SwiftUI's equivalent of UIApplicationDelegate; defines the root Scene
WindowGroup The content window — handles multitasking, multiple windows on iPad
@StateObject Creates an ObservableObject instance that lives for the lifetime of the owning view; use for top-level objects
@State Owned local reactive value; changes trigger a re-render of body
@UIApplicationDelegateAdaptor Bridges old UIKit lifecycle events (needed for Firebase, Push Notifications) into a SwiftUI app
.environmentObject() Injects an ObservableObject into the SwiftUI environment so any descendant view can access it with @EnvironmentObject
NavigationStack(path:) Programmatic navigation using a NavigationPath stack
.navigationDestination(for:) Declares which view to show for each route type

Views Layer

The View Protocol

In SwiftUI every UI element is a View. A View is a struct that conforms to the View protocol, which requires exactly one thing: a body property that returns some View.

struct MyView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

some View is an opaque return type — it tells Swift "this returns some specific View type, but I don't need to spell out which one." The compiler figures it out.

SwiftUI views are value types (structs). This is intentional — they are cheap to create, re-create, and compare, making the diffing system fast.


Layout Containers: VStack, HStack, ZStack

These three containers replace UIKit's Auto Layout system entirely.

VStack — Vertical Stack

Arranges children top-to-bottom. Every screen you build will almost certainly have a root VStack.

VStack(alignment: .leading, spacing: 16) {
    Text("Title")
        .font(.title)
    Text("Subtitle")
        .foregroundColor(.secondary)
    Button("Tap me") { }
}
  • alignment: .leading, .center, .trailing — how items align on the cross axis
  • spacing: fixed gap between each child

HStack — Horizontal Stack

Arranges children left-to-right.

HStack(alignment: .top, spacing: 12) {
    Image(systemName: "person.circle")
    VStack(alignment: .leading) {
        Text("Lokesh Jangid")
            .font(.headline)
        Text("iOS Developer")
            .font(.caption)
    }
    Spacer()   // pushes everything to the left
}

Spacer() is a flexible space that expands to fill available room — used to push items to either end of a stack.

ZStack — Depth Stack

Layers children on top of each other (Z axis). Used for backgrounds, overlays, badges.

ZStack {
    LinearGradient(
        colors: [.blue, .purple],
        startPoint: .topLeading,
        endPoint: .bottomTrailing
    )
    .ignoresSafeArea()                      // gradient covers entire screen

    VStack {
        Text("FinSight AI")
            .font(.largeTitle)
            .foregroundColor(.white)
    }
}

Other important containers used in FinSightAI

Container What it does
ScrollView Scrollable content in any direction
LazyVStack Like VStack but only renders items when they're visible — critical for long chat lists
ScrollViewReader Programmatically scroll to a specific view by ID (used to scroll to the latest message)
ForEach Renders a collection of views from an array
Group Groups views without adding layout (useful for conditional logic)

SplashScreen.swift

The splash screen shows for 1 second on launch, then removes itself using @Binding.

import SwiftUI

struct SplashScreen: View {
    // @Binding creates a two-way connection to a value owned by the parent
    @Binding var showSplash: Bool

    var body: some View {
        VStack(spacing: 20) {
            Image(systemName: "wand.and.stars.inverse")
                .font(.system(size: 60))
            Text("FinSight AI")
                .font(.largeTitle)
        }
        .task {
            // .task attaches an async task to the view's lifecycle
            // It runs when the view appears and cancels if the view disappears
            try? await Task.sleep(for: .seconds(1))
            showSplash = false   // writes back through the binding — parent's @State updates
        }
    }
}

#Preview {
    SplashScreen(showSplash: .constant(true))
    // .constant() creates a Binding that never changes — good for previews
}

Key concepts

Concept What it does
@Binding A reference to a value owned somewhere else. Reading it reads the parent's value; writing it writes the parent's value. No data is duplicated.
.task { } SwiftUI modifier that runs an async closure tied to the view's appearance. Automatically cancelled when the view disappears. Modern replacement for onAppear + Task { }.
Task.sleep(for:) Suspends the current async task without blocking any thread. Requires try? because it can be cancelled.
#Preview Xcode 15+ macro that registers a preview. Shown in the canvas without needing to run the simulator.

HomeScreen.swift

The main screen of the app. This is the most instructive file — it shows how Views and ViewModels connect.

import SwiftUI

struct HomeScreen: View {
    // @StateObject creates the ViewModel and owns it
    // Use @StateObject when THIS view creates the object
    @StateObject private var chatModel = ChatViewModel()

    var body: some View {
        VStack(spacing: 0) {

            // ── Message list ─────────────────────────────
            ScrollViewReader { proxy in
                // proxy lets us programmatically scroll to a view by ID
                ScrollView {
                    LazyVStack(spacing: 10) {
                        ForEach(chatModel.messages) { message in
                            ChatBubble(message: message)
                                .id(message.id)   // gives each bubble an anchor ID
                        }
                    }
                    .padding(.vertical)
                }
                // Fires when messages.count changes → scroll to the new message
                .onChange(of: chatModel.messages.count) { oldValue, newValue in
                    if let lastMessage = chatModel.messages.last {
                        withAnimation {
                            proxy.scrollTo(lastMessage.id, anchor: .bottom)
                        }
                    }
                }
            }

            // ── Input bar ────────────────────────────────
            HStack(spacing: 12) {
                // Multi-line expandable text field (axis: .vertical)
                TextField(
                    "Input a stock name to get information",
                    text: $chatModel.inputText,   // two-way binding to ViewModel's @Published var
                    axis: .vertical
                )
                .onSubmit { chatModel.send() }    // fires when user taps Return
                .textFieldStyle(.plain)
                .padding(10)
                .background(Color(.systemGray6))
                .clipShape(RoundedRectangle(cornerRadius: 20))
                .lineLimit(1...5)

                // Show spinner while AI is thinking, else show send button
                if chatModel.isLoading {
                    ProgressView()
                } else {
                    Button(action: chatModel.send) {
                        Image(systemName: "arrow.up.circle.fill")
                            .font(.system(size: 32))
                            .foregroundStyle(
                                chatModel.inputText.isEmpty ? .gray : .blue
                            )
                    }
                    .disabled(chatModel.inputText.isEmpty)
                }
            }
            .padding(.horizontal)
            .padding(.vertical, 8)
            .background(Color(.systemBackground))
        }
        .navigationTitle("FinSight AI")
    }
}

Key concepts

Concept What it does
@StateObject Creates and owns an ObservableObject. The view that creates it should use @StateObject; views that receive it should use @ObservedObject.
$chatModel.inputText The $ prefix turns a @Published property into a Binding<String>, enabling two-way sync between the TextField and the ViewModel's value.
ForEach Iterates over a collection that conforms to Identifiable and renders a view for each item.
ScrollViewReader Gives access to a ScrollViewProxy that can call .scrollTo(id:) to jump to any view with a matching .id() modifier.
.onChange(of:) A view modifier that fires a closure when a value changes. Used here to auto-scroll when messages are added.
withAnimation Wraps a state change in an implicit animation transaction so the scroll movement is smooth.
LazyVStack Renders items lazily — only those currently visible on screen are in memory. Essential for chat lists.
Button(action:) Creates a tappable button. The closure runs on tap. .disabled() greyes it out and prevents taps.
ProgressView() Renders the system spinner. No configuration needed for the default indeterminate spinner.
TextField(_:text:axis:) Multi-line text input with the axis: .vertical parameter (iOS 16+). Grows vertically up to lineLimit.

ChatBubble.swift (Reusable Element)

The elements/ subfolder is a great pattern: it holds small, reusable view components that are not full screens. ChatBubble is the single UI element that renders both user messages and AI responses.

import SwiftUI

struct ChatBubble: View {
    let message: ChatMessage   // passed in from the parent — no @State needed for display-only data

    var body: some View {
        HStack(alignment: .top, spacing: 0) {
            // Push user bubbles to the right with a leading Spacer
            if message.isMe { Spacer(minLength: 50) }

            VStack(alignment: message.isMe ? .trailing : .leading, spacing: 0) {
                if message.isMe {
                    Text(message.text)
                        .padding(12)
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .clipShape(RoundedRectangle(cornerRadius: 16))
                } else {
                    // AI response — render as markdown
                    MarkdownText(message.text)
                        .padding(12)
                        .background(Color.gray.opacity(0.15))
                        .clipShape(RoundedRectangle(cornerRadius: 16))
                }
            }

            // Push AI bubbles to the left with a trailing Spacer
            if !message.isMe { Spacer(minLength: 50) }
        }
        .padding(.horizontal)
    }
}

// Sub-view that renders Markdown using AttributedString
struct MarkdownText: View {
    let text: String

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            if let attributed = try? AttributedString(
                markdown: text,
                options: AttributedString.MarkdownParsingOptions(
                    interpretedSyntax: .inlineOnlyPreservingWhitespace
                )
            ) {
                Text(attributed)
                    .textSelection(.enabled)   // user can long-press to copy
                    .font(.body)
                    .foregroundColor(.primary)
            } else {
                Text(text)    // fallback if markdown parsing fails
                    .textSelection(.enabled)
                    .font(.body)
                    .foregroundColor(.primary)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

Key concepts

Concept What it does
Stored property (let message) Views that only display data (no user interaction changes it) just take a let — no state wrapper needed.
Conditional views (if message.isMe) SwiftUI builds different view trees depending on runtime conditions. Each branch is fully independent.
.clipShape(RoundedRectangle(cornerRadius:)) Clips the view to a rounded rectangle shape — the standard way to make rounded bubbles.
Spacer(minLength:) Flexible space. minLength ensures it never collapses below that size, keeping the bubble from stretching full-width.
AttributedString(markdown:) Parses a markdown string into a rich attributed string with bold, italic, lists, etc. Introduced in iOS 15.
.textSelection(.enabled) Lets the user long-press to select and copy text — important for AI responses.
Color.gray.opacity(0.15) Semi-transparent colour — adapts automatically between Light and Dark Mode.

SettingsScreen.swift & AboutScreen.swift

SettingsScreen is a placeholder — it demonstrates the minimum valid SwiftUI view:

struct SettingsScreen: View {
    var body: some View {
        Text("Settings")
    }
}

Every production app starts this way. The view can always be expanded later.

AboutScreen uses a ZStack to layer a LinearGradient background behind content:

struct AboutScreen: View {
    var body: some View {
        ZStack {
            LinearGradient(
                colors: [.blue, .purple],
                startPoint: .topLeading,
                endPoint: .bottomTrailing
            )
            // ... content on top
        }
    }
}

LinearGradient takes an array of Color values and interpolates between them from startPoint to endPoint. Both points are UnitPoint values: .topLeading, .bottomTrailing, .center, etc.


ViewModels Layer

What is ObservableObject?

ObservableObject is a protocol from the Combine framework. When a class conforms to it, its @Published properties become publishers. Any SwiftUI view observing this object (via @StateObject or @ObservedObject) automatically re-renders when any @Published property changes.

ViewModel (@Published var messages changes)
         │
         └──► SwiftUI diffs the View tree
                   │
                   └──► Re-renders only the affected parts

This is the reactive binding at the core of MVVM in SwiftUI.


AIStudioViewModel.swift — ChatViewModel

import Foundation
import SwiftUI
import Combine

// @MainActor guarantees all @Published mutations happen on the main thread
// (required for UI updates)
@MainActor
final class ChatViewModel: ObservableObject {

    @Published var messages: [ChatMessage] = []   // the message list — drives the UI
    @Published var isLoading = false              // shows/hides the spinner
    @Published var inputText = ""                 // bound to the TextField

    // The networking layer — dependency injected (easy to swap for testing)
    private let service = GeminiService()

    func send() {
        let userText = inputText.trimmingCharacters(in: .whitespaces)
        guard !userText.isEmpty else { return }   // guard: early exit pattern

        inputText = ""                             // clear the input field immediately
        messages.append(ChatMessage(text: userText, isMe: true))

        // Launch a concurrent Task to call the API without blocking the UI
        Task {
            await fetchAIResponse(for: userText)
        }
    }

    private func fetchAIResponse(for text: String) async {
        isLoading = true

        do {
            let reply = try await service.sendMessage(text)
            // MainActor.run ensures this UI update definitely runs on main thread
            await MainActor.run {
                messages.append(ChatMessage(text: reply, isMe: false))
                isLoading = false
            }
        } catch {
            await MainActor.run {
                messages.append(
                    ChatMessage(text: "Something went wrong. Please try again.", isMe: false)
                )
                isLoading = false
            }
        }
    }
}

Key concepts

Concept What it does
@MainActor A global actor that confines all method calls and property access to the main thread. Applying it to a class means every method in the class is automatically dispatched to the main thread — no DispatchQueue.main.async needed.
ObservableObject Makes this class publishable. SwiftUI subscribes to it automatically via @StateObject/@ObservedObject.
@Published Wraps a stored property so changes emit through a Combine publisher. Any subscribed View re-renders.
final class Prevents subclassing — a best practice for ViewModels since they are never meant to be subclassed.
guard !userText.isEmpty else { return } The guard statement checks a condition and exits the current scope if it fails. The else clause must exit (return, throw, break, etc.). It's the idiomatic Swift way to validate preconditions.
Task { } Creates a new concurrent unit of work. Here it detaches the async API call from the synchronous send() method.
async/await Swift Concurrency model. async marks a function that can suspend. await suspends the caller until the async function returns. No callback hell, no completion handlers.
do { try await } catch { } Error handling for async throwing functions. try await calls an async throws function; any thrown error is caught in catch.
await MainActor.run { } Explicitly hops back to the main thread for a block of code. Useful inside a non-@MainActor async context.
trimmingCharacters(in: .whitespaces) Removes leading/trailing whitespace from a String.

GeminiService.swift — Networking

This is the Networking sublayer of the ViewModel layer. It handles all HTTP communication with the Gemini API.

import Foundation

final class GeminiService {

    private let apiKey = "<api_key>"
    private let baseURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:generateContent"

    // `async throws` — this function is both asynchronous and can fail
    func sendMessage(_ message: String) async throws -> String {
        guard let url = URL(string: baseURL) else {
            throw URLError(.badURL)   // throw stops execution; caller must catch
        }

        let systemPrompt = """
        You are a helpful financial education assistant for beginner investors in India.
        ... (full system prompt)
        """

        // Build the JSON request body as a Swift dictionary
        let body: [String: Any] = [
            "contents": [
                [
                    "parts": [
                        ["text": systemPrompt + "\nUser question: \(message)"]
                    ]
                ]
            ]
        ]

        // Serialize dictionary → Data (JSON bytes)
        let jsonData = try JSONSerialization.data(withJSONObject: body)

        // Construct the HTTP request
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = jsonData

        // Perform the network call — suspends until the response arrives
        // Returns (Data, URLResponse) — we only need Data here
        let (data, _) = try await URLSession.shared.data(for: request)

        // Decode the JSON response into our Model struct
        let response = try JSONDecoder().decode(GeminiResponse.self, from: data)

        // Navigate the nested response structure to get the text
        return response.candidates.first?.content.parts.first?.text ?? "No response"
    }
}

Network request lifecycle

1. Build URL from stringURL(string:)
2. Build request body dictionary[String: Any]
3. Serialize to JSON bytesJSONSerialization.data(withJSONObject:)
4. Configure URLRequesthttpMethod, headers, httpBody
5. Execute requestURLSession.shared.data(for:)   ← suspends here
6. Decode responseJSONDecoder().decode(_:from:)
7. Return extracted textoptional chaining + nil coalescing

Key concepts

Concept What it does
URLRequest Represents a single HTTP request — method, headers, body, timeout, cache policy.
URLSession.shared The default singleton session. Uses the system's networking stack, respects system proxy settings, handles cookies, caching.
.data(for: request) Async version of the network call. Returns (Data, URLResponse) — the raw response bytes and metadata.
JSONSerialization.data(withJSONObject:) Converts a Foundation object graph ([String: Any]) to JSON-encoded Data. Used when you need dynamic/arbitrary JSON structures.
JSONDecoder().decode(_:from:) Decodes JSON Data into a Decodable struct. Type-safe, compile-time checked. Preferred over JSONSerialization for known response shapes.
request.setValue(_:forHTTPHeaderField:) Sets an HTTP header. Here sets Content-Type: application/json and the Google API key.
throw URLError(.badURL) Throws an error immediately. URLError is the standard Foundation error for URL-related failures.
Optional chaining (?.) response.candidates.first?.content.parts.first?.text — safely navigates a chain; returns nil at the first nil without crashing.
Nil coalescing (?? "No response") Provides a default value if the left side is nil.

Models Layer

ChatMessageModel.swift

import Foundation

// A single chat message — conforms to three protocols
struct ChatMessage: Codable, Identifiable {
    let id: UUID          // Identifiable requires an `id`
    let text: String
    let isMe: Bool        // true = user bubble, false = AI bubble

    // Custom init provides a default UUID so callers don't have to supply one
    init(id: UUID = UUID(), text: String, isMe: Bool) {
        self.id = id
        self.text = text
        self.isMe = isMe
    }
}

// Nested Decodable structs mirror the JSON response structure from Gemini
struct GeminiResponse: Decodable {
    let candidates: [Candidate]

    struct Candidate: Decodable {
        let content: Content
    }

    struct Content: Decodable {
        let parts: [Part]
    }

    struct Part: Decodable {
        let text: String
    }
}

The GeminiResponse structure mirrors the actual JSON returned by the Gemini API:

{
  "candidates": [
    {
      "content": {
        "parts": [
          { "text": "Apple Inc. is a global technology company..." }
        ]
      }
    }
  ]
}

Swift's JSONDecoder automatically maps JSON keys to struct property names, recursively decoding nested objects.

Key Protocols: Codable, Identifiable, Decodable

Protocol What it does When to use
Identifiable Requires an id property. Enables use in ForEach without explicitly specifying the key path. Any model displayed in a list.
Codable Combines Encodable (Swift → JSON) and Decodable (JSON → Swift). The compiler auto-synthesises the implementation if all properties are themselves Codable. Models that are both sent to and received from an API, or persisted to disk.
Decodable JSON → Swift only. No encoding needed. API response models you never need to serialise back.
Encodable Swift → JSON only. Request bodies, local storage.
Hashable Enables use in sets, dictionary keys, and NavigationPath. Route enums, identifiers.

UUID

UUID generates a universally unique identifier — a 128-bit value guaranteed to be unique. UUID() generates a new one each time. It conforms to Codable, Hashable, and Identifiable-friendly, making it the standard choice for model IDs in Swift.

let id = UUID()
// Example: 550E8400-E29B-41D4-A716-446655440000

Resources

Assets.xcassets

The Assets.xcassets asset catalog is the central store for all visual assets in an iOS app. It is not just a folder — Xcode reads it at build time to generate optimised asset bundles for each device.

What goes in it:

Asset Type Description
App Icon AppIcon — set of icons at various resolutions for different devices (1x, 2x, 3x)
Image Sets Each image set holds 1x/2x/3x variants. SwiftUI/UIKit automatically loads the right one for the screen density.
Color Sets Named colours with Light Mode and Dark Mode variants. Reference in code as Color("MyColor").
Symbol Configurations SF Symbols customisation.

How to use image assets in SwiftUI:

// From the asset catalog
Image("logo")

// SF Symbols (Apple's built-in icon library — no assets needed)
Image(systemName: "arrow.up.circle.fill")
    .font(.system(size: 32))
    .foregroundStyle(.blue)

// Resizable image that fills its frame
Image("background")
    .resizable()
    .scaledToFill()
    .frame(width: 100, height: 100)
    .clipped()

How to add a named colour and use it:

// In code (after defining "BrandBlue" in Assets.xcassets)
Color("BrandBlue")

// Or use a Color extension (common pattern)
extension Color {
    static let brand = Color("BrandBlue")
}

Text("Hello").foregroundColor(.brand)

GoogleService-Info.plist

A plist (Property List) is an XML-based key-value configuration file. Apple uses them extensively throughout iOS development.

GoogleService-Info.plist is the Firebase configuration file that contains:

<key>PROJECT_ID</key>       <string>finsightai</string>
<key>GOOGLE_APP_ID</key>    <string>1:xxx:ios:xxx</string>
<key>API_KEY</key>          <string>AIzaSy...</string>
<key>GCM_SENDER_ID</key>    <string>12345</string>

When FirebaseApp.configure() is called in AppDelegate, Firebase reads this file from the app bundle to identify the project and initialise all services (Analytics, Crashlytics, Cloud Messaging, etc.).

Other common plist uses in iOS:

File Purpose
Info.plist App-wide permissions (camera, location), URL schemes, version numbers
GoogleService-Info.plist Firebase project configuration
Custom .plist App configuration constants, feature flags, API base URLs

Reading a custom plist in Swift:

// Reading from a custom Config.plist
guard let path = Bundle.main.path(forResource: "Config", ofType: "plist"),
      let dict = NSDictionary(contentsOfFile: path),
      let apiKey = dict["API_KEY"] as? String else {
    fatalError("Config.plist missing or malformed")
}

Routes — Navigation

Route.swift

Navigation in modern SwiftUI (iOS 16+) is handled via NavigationStack and NavigationPath. FinSightAI centralises all navigation logic in a single Route class.

import SwiftUI
import Combine

// final = cannot be subclassed
// ObservableObject = SwiftUI can observe and react to changes
final class Route: ObservableObject {

    // NavigationPath is a type-erased stack of Hashable values
    // It drives the NavigationStack in FinSightAIApp
    @Published var navPath = NavigationPath()

    // All possible routes in the app as a type-safe enum
    enum RouteName: Hashable, Codable {
        case home
        case settings
        case about
    }

    // Push a new screen onto the navigation stack
    func push(path: RouteName) {
        navPath.append(path)
    }

    // Go back one screen
    func pop() {
        navPath.removeLast()
    }

    // Go back to the root screen
    func popToRoot() {
        navPath.removeLast(navPath.count)
    }

    // Go back N screens
    func popTill(count: Int) {
        navPath.removeLast(count)
    }
}

How to navigate from any view:

struct SomeView: View {
    // Retrieve the Route object from the environment
    @EnvironmentObject var route: Route

    var body: some View {
        Button("Open Settings") {
            route.push(path: .settings)    // navigates to SettingsScreen
        }

        Button("Go Home") {
            route.popToRoot()              // pops entire stack
        }
    }
}

Before iOS 16, SwiftUI had NavigationView which was limited and buggy. iOS 16 introduced NavigationStack — a complete redesign.

// Root app structure (from FinSightAIApp.swift)
NavigationStack(path: $route.navPath) {    // path drives the stack
    HomeScreen()
        .navigationDestination(for: Route.RouteName.self) { destination in
            switch destination {
            case .home:     HomeScreen()
            case .settings: SettingsScreen()
            case .about:    AboutScreen()
            }
        }
}
.environmentObject(route)
Old (NavigationView) New (NavigationStack)
Each link defines its own destination All destinations declared centrally in .navigationDestination
Programmatic navigation awkward Full control via NavigationPath
Nested NavigationView bugs Type-safe stack with push/pop
No popToRoot navPath.removeLast(navPath.count)

The flow:

1. Route.push(path: .settings)
         │
         ▼
2. navPath.append(Route.RouteName.settings)
         │
         ▼
3. NavigationStack detects navPath changed
         │
         ▼
4. Looks up .navigationDestination(for: Route.RouteName.self)
         │
         ▼
5. Renders SettingsScreen() on top of the stack

Additional navigation modifiers used in the app

// Sets the navigation bar title for the current screen
.navigationTitle("FinSight AI")

// Controls the size of the navigation title (inline = small, automatic = large)
.navigationBarTitleDisplayMode(.large)

// Adds a button to the navigation bar
.toolbar {
    ToolbarItem(placement: .navigationBarTrailing) {
        Button("About") { route.push(path: .about) }
    }
}

Data Flow: End-to-End

Here is the complete flow when a user types "Reliance" and taps Send:

1. [User]        Types "Reliance" into TextField
                 → TextField bound to $chatModel.inputText via two-way Binding

2. [User]        Taps the send Button
                 → Button calls chatModel.send()

3. [ChatViewModel.send()]
                 → Trims whitespace, checks not empty (guard)
                 → Clears inputText ("")
                 → Appends ChatMessage(text: "Reliance", isMe: true) to messages
                 → @Published messages fires → View re-renders → user bubble appears
                 → Launches Task { await fetchAIResponse(for: "Reliance") }

4. [ChatViewModel.fetchAIResponse()]
                 → Sets isLoading = true → @Published fires → spinner appears
                 → Calls try await service.sendMessage("Reliance")

5. [GeminiService.sendMessage()]
                 → Constructs URL from baseURL string
                 → Builds JSON body dict with systemPrompt + user message
                 → JSONSerialization encodes dict → Data
                 → Builds URLRequest: POST, API key header, Content-Type header
                 → try await URLSession.shared.data(for: request) ← suspends
                 → Gemini API processes and returns JSON response
                 → Resumes with (Data, URLResponse)
                 → JSONDecoder decodes Data → GeminiResponse struct
                 → Returns response.candidates[0].content.parts[0].text

6. [Back in fetchAIResponse()]
                 → Receives reply String
                 → await MainActor.run { ... }
                 → Appends ChatMessage(text: reply, isMe: false) to messages
                 → Sets isLoading = false
                 → @Published fires → View re-renders → AI bubble + MarkdownText appear

7. [HomeScreen ScrollViewReader]
                 → .onChange(of: chatModel.messages.count) fires
                 → proxy.scrollTo(lastMessage.id, anchor: .bottom)
                 → ScrollView animates to show the new AI response

Key Swift Concepts Glossary

Term Definition
struct Value type. Copied when assigned. Used for Views and Models in SwiftUI. Immutable by default.
class Reference type. Shared when assigned. Used for ViewModels (ObservableObject).
final Prevents a class from being subclassed. Applied to ViewModels as a best practice.
@State Privately owned reactive value inside a View. Changes trigger re-render. For simple local UI state.
@Binding A reference to a @State value owned by a parent. Two-way; reads and writes the parent's value.
@StateObject Creates and owns an ObservableObject. Lives as long as the owning view.
@ObservedObject References an ObservableObject owned elsewhere. Observes it for changes.
@EnvironmentObject Retrieves an ObservableObject injected into the SwiftUI environment via .environmentObject().
@Published Property wrapper on ObservableObject properties. Emits a change event through Combine when set.
@MainActor Confines code to the main thread. Applying to a class makes all its methods main-thread-safe.
async Marks a function as asynchronous — it can suspend without blocking its thread.
await Suspends the current task until the awaited async operation completes.
throws Marks a function as capable of throwing errors. Callers must use try.
guard Evaluates a condition; the else block must exit scope. Used for precondition validation.
Codable Auto-synthesised JSON encode/decode. Combines Encodable + Decodable.
Identifiable Requires an id property. Enables ForEach to track items uniquely.
some View An opaque return type — "some specific View type the compiler knows about." Used for body.
NavigationPath A type-erased stack that drives NavigationStack. Push/pop to navigate programmatically.
Task { } Creates a new concurrent unit of work from synchronous context.
@discardableResult Suppresses the warning when a function's return value is not used.
optional chaining (?.) Safely accesses a property on an optional value; returns nil if the optional is nil.
nil coalescing (??) Returns the left value if non-nil, otherwise returns the right default value.

Summary

FinSightAI is a clean, production-quality example of a modern iOS MVVM app. Here is what each layer contributes:

Layer Files Responsibility
Views HomeScreen, SplashScreen, ChatBubble, etc. Declare UI. Bind to ViewModel state. Forward user actions.
ViewModels ChatViewModel, GeminiService Own app state (@Published). Call API. Handle errors. Transform raw data for Views.
Model ChatMessage, GeminiResponse Pure data. No UIKit/SwiftUI imports. Fully portable.
Resources Assets.xcassets, plist files Images, colours, icons, configuration constants.
Routes Route.swift Single source of truth for navigation. Push, pop, and popToRoot from anywhere via @EnvironmentObject.

The most important principle is separation of concerns — Views know nothing about HTTP, Models know nothing about navigation, and ViewModels never import SwiftUI directly. This makes each layer independently testable, reusable, and maintainable.


Based on the open-source FinSightAI project by Lokesh Jangid.

Post a Comment

Previous Post Next Post

Subscribe Us


Get tutorials, Flutter news and other exclusive content delivered to your inbox. Join 1000+ growth-oriented Flutter developers subscribed to the newsletter

100% value, 0% spam. Unsubscribe anytime