SPB Git

spb/poche Public

Agent personnel 100 % on-device — SwiftUI + Apple Foundation Models + EventKit + SwiftData. Aucune API, aucun serveur.

Swift 100%
3.4 KB · 104 lines swift
Raw Blame History
1//2//  ChatView.swift3//  Poche4//5//  Author:  Simon-Pierre Boucher6//  Contact: contact@spboucher.ai7//89import SwiftUI1011/// Single screen: thread, pending confirmations, composer.12/// No tabs, no settings menu on first launch (CLAUDE.md §6).13struct ChatView: View {14    @Environment(AppDependencies.self) private var deps1516    var body: some View {17        @Bindable var chat = deps.chat1819        ZStack {20            AppBackground()2122            VStack(spacing: 0) {23                header2425                if chat.turns.isEmpty {26                    WelcomeView { suggestion in27                        Task { await chat.send(suggestion) }28                    }29                } else {30                    thread(chat: chat)31                }3233                ForEach(deps.confirm.pending) { action in34                    ConfirmationCardView(35                        action: action,36                        onConfirm: { Task { await deps.confirm.confirm(action) } },37                        onModify: { deps.confirm.dismiss(action) }38                    )39                    .padding(.horizontal, Theme.spacingMedium)40                    .padding(.bottom, Theme.spacingSmall)41                    .transition(.move(edge: .bottom).combined(with: .opacity))42                }4344                ComposerView(45                    text: $chat.draft,46                    isBusy: chat.isBusy,47                    onSend: { Task { await chat.send() } }48                )49            }50            .animation(.spring(duration: 0.35), value: deps.confirm.pending.count)51        }52        .fontDesign(.rounded)53        .task {54            #if DEBUG55            // Deterministic driving hooks for simulator verification only.56            let env = ProcessInfo.processInfo.environment57            if env["POCHE_DEMO_THREAD"] != nil, chat.turns.isEmpty {58                chat.injectDemoTurns()59            }60            if env["POCHE_DEMO_CARD"] != nil, deps.confirm.pending.isEmpty {61                deps.confirm.propose(PendingAction(payload: .reminder(62                    ReminderDraft(title: "Appeler le dentiste", due: .now.addingTimeInterval(86_400), list: nil)63                )))64            }65            if let text = env["POCHE_AUTOSEND"], chat.turns.isEmpty {66                await chat.send(text)67            }68            #endif69        }70    }7172    private var header: some View {73        VStack(spacing: 0) {74            HStack(spacing: Theme.spacingSmall + 2) {75                BrandMark(size: 30)76                Text("Poche")77                    .font(.title3.weight(.bold))78                Spacer()79            }80            .padding(.horizontal, Theme.spacingMedium + 4)81            .padding(.vertical, Theme.spacingSmall + 2)8283            ContextGaugeView(ratio: deps.agent.usageRatio)84                .padding(.horizontal, Theme.spacingMedium)85        }86    }8788    private func thread(chat: ChatViewModel) -> some View {89        ScrollView {90            LazyVStack(spacing: Theme.spacingMedium) {91                ForEach(chat.turns) { turn in92                    TurnBubbleView(turn: turn)93                        .transition(.move(edge: .bottom).combined(with: .opacity))94                }95            }96            .padding(.horizontal, Theme.spacingMedium)97            .padding(.vertical, Theme.spacingLarge)98            .animation(.spring(duration: 0.35), value: chat.turns.count)99        }100        .defaultScrollAnchor(.bottom)101        .scrollDismissesKeyboard(.interactively)102    }103}104