diff --git a/docs/superpowers/plans/2026-05-12-phase-1-schema-migration.md b/docs/superpowers/plans/2026-05-12-phase-1-schema-migration.md new file mode 100644 index 0000000..2c430a8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-phase-1-schema-migration.md @@ -0,0 +1,1109 @@ +# Phase 1: Schema + Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `Conference`, `ChatThread`, `ChatMessage` SwiftData models, attach `Note.conference` relationship, add `Note.speaker`, and run a one-time idempotent migration that groups existing notes by `conferenceName` into `Conference` records. + +**Architecture:** Pure additive SwiftData changes (new entities + new optional fields). No `VersionedSchema` — SwiftData's default lightweight migration handles additive changes. Migration follows the existing `PhotoMigration` pattern: a static `run(in:)` function gated by a `UserDefaults` flag, invoked from `MuesliApp.init`. + +**Tech Stack:** Swift 5.9+, SwiftData, Swift Testing framework (`import Testing`), with XCTest for the migration test (matching `PhotoMigrationTests` precedent). + +**Spec reference:** `docs/superpowers/specs/2026-05-12-gap-close-design.md` § Data model, Schema versioning + migration. + +**Deviation note vs. spec:** Spec proposed `VersionedSchema` chain `SchemaV1 → SchemaV2`. After looking at the existing setup (`MuesliApp.swift:14-17` uses a flat `Schema([Note.self, Photo.self])`), I'm skipping `VersionedSchema` because every change in this phase is purely additive — new entities and new optional fields. SwiftData handles additive changes via lightweight migration without explicit version chains. We can introduce `VersionedSchema` later if a destructive change ever needs it. + +--- + +## File Structure + +**Creating:** +- `src/mobile/Muesli/Models/Conference.swift` — new entity +- `src/mobile/Muesli/Models/ChatThread.swift` — new entity +- `src/mobile/Muesli/Models/ChatMessage.swift` — new entity +- `src/mobile/Muesli/Migration/ConferenceMigration.swift` — one-time backfill +- `src/mobile/MuesliTests/Models/ConferenceModelTests.swift` — model unit tests +- `src/mobile/MuesliTests/Models/ChatThreadModelTests.swift` — model unit tests +- `src/mobile/MuesliTests/Models/ConferenceMigrationTests.swift` — migration tests + +**Modifying:** +- `src/mobile/Muesli/Models.swift` — add `speaker: String?`, add `conference: Conference?` relationship to `Note` +- `src/mobile/Muesli/MuesliApp.swift` — register new entities in schema; invoke migration on launch +- `src/mobile/Muesli/SampleData/SampleDataManager.swift` — seed two conferences with multi-talk groupings + +--- + +## Task 1: Create `Conference` model + +**Files:** +- Create: `src/mobile/Muesli/Models/Conference.swift` +- Test: `src/mobile/MuesliTests/Models/ConferenceModelTests.swift` + +- [ ] **Step 1: Write the failing tests** + +Create `src/mobile/MuesliTests/Models/ConferenceModelTests.swift`: + +```swift +// +// ConferenceModelTests.swift +// MuesliTests +// +// Unit tests for the Conference SwiftData entity. +// + +import Testing +import SwiftData +import Foundation +@testable import Muesli + +@Suite("Conference Model Tests", .tags(.unit)) +struct ConferenceModelTests { + + @Test("Conference initialization with required fields") + func conferenceInitialization() async throws { + let conf = Conference(name: "DataSummit 2026") + + #expect(conf.name == "DataSummit 2026") + #expect(conf.location == nil) + #expect(conf.startDate == nil) + #expect(conf.endDate == nil) + #expect(conf.conferenceDescription == nil) + #expect(conf.notes.isEmpty) + #expect(conf.createdAt.timeIntervalSinceNow < 1) + } + + @Test("Conference initialization with all metadata") + func conferenceFullInit() async throws { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let end = Date(timeIntervalSince1970: 1_700_200_000) + let conf = Conference( + name: "DataSummit 2026", + location: "San Francisco", + startDate: start, + endDate: end, + conferenceDescription: "Annual data conference" + ) + + #expect(conf.location == "San Francisco") + #expect(conf.startDate == start) + #expect(conf.endDate == end) + #expect(conf.conferenceDescription == "Annual data conference") + } + + @Test("Conference has stable UUID") + func conferenceStableID() async throws { + let id = UUID() + let conf = Conference(id: id, name: "X") + #expect(conf.id == id) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./scripts/test.sh unit` +Expected: FAIL with "Cannot find 'Conference' in scope" (and similar) — the type doesn't exist yet. + +- [ ] **Step 3: Create the Conference model** + +Create `src/mobile/Muesli/Models/Conference.swift`: + +```swift +// +// Conference.swift +// Muesli +// +// SwiftData entity representing a conference, grouping multiple Note talks. +// + +import Foundation +import SwiftData + +@Model +final class Conference { + var id: UUID + var name: String + var location: String? + var startDate: Date? + var endDate: Date? + var conferenceDescription: String? // `description` is reserved on NSObject + var createdAt: Date + + @Relationship(deleteRule: .nullify, inverse: \Note.conference) + var notes: [Note] = [] + + init( + id: UUID = UUID(), + name: String, + location: String? = nil, + startDate: Date? = nil, + endDate: Date? = nil, + conferenceDescription: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.name = name + self.location = location + self.startDate = startDate + self.endDate = endDate + self.conferenceDescription = conferenceDescription + self.createdAt = createdAt + } +} +``` + +Note: This will not compile yet because `Note.conference` is referenced by `inverse:` but does not exist. Task 2 adds it. The plan completes the compilation cycle there. + +- [ ] **Step 4: Skip running tests until Task 2 (the inverse reference needs `Note.conference` first)** + +Move directly to Task 2. Do not commit yet. + +--- + +## Task 2: Add `Note.conference` relationship and `Note.speaker` + +**Files:** +- Modify: `src/mobile/Muesli/Models.swift` +- Test: `src/mobile/MuesliTests/Models/NoteModelTests.swift` (extend existing) + +- [ ] **Step 1: Write the failing tests** + +Append to `src/mobile/MuesliTests/Models/NoteModelTests.swift` (inside the existing `NoteModelTests` struct): + +```swift + @Test("Note speaker defaults to nil") + func noteSpeakerDefault() async throws { + let note = Note(title: "Talk") + #expect(note.speaker == nil) + } + + @Test("Note speaker can be set") + func noteSpeakerSet() async throws { + let note = Note(title: "Talk", speaker: "Sarah Chen") + #expect(note.speaker == "Sarah Chen") + } + + @Test("Note conference relationship is nil by default") + func noteConferenceDefault() async throws { + let note = Note(title: "Talk") + #expect(note.conference == nil) + } + + @Test("Note can be attached to Conference") + func noteConferenceRelationship() async throws { + let schema = Schema([Note.self, Photo.self, Conference.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [config]) + let context = ModelContext(container) + + let conf = Conference(name: "DataSummit 2026") + let note = Note(title: "Talk") + note.conference = conf + context.insert(conf) + context.insert(note) + try context.save() + + #expect(note.conference?.name == "DataSummit 2026") + #expect(conf.notes.count == 1) + #expect(conf.notes.first?.title == "Talk") + } +``` + +- [ ] **Step 2: Run tests to verify they fail to compile** + +Run: `./scripts/test.sh unit` +Expected: FAIL — `Note` has no `speaker` or `conference` member. + +- [ ] **Step 3: Modify `Note` model** + +Edit `src/mobile/Muesli/Models.swift`. Replace the existing `@Relationship` block and the trailing initializer body to add the new fields. The full updated section between line 17 and the end of `init(...)` should read: + +```swift + var conferenceName: String? + var sessionType: String // "meeting", "session", "note" + var isArchived: Bool + var audioFilePath: String? // Local path to audio file + var transcriptionStatus: String // "none", "pending", "processing", "completed", "failed" + var duration: TimeInterval? // Recording duration in seconds + + // SwiftData doesn't handle Optional arrays well, use empty array as default + var imagePaths: [String] = [] // Array of local file paths to captured images + + var aiSummary: String? // AI-generated summary of the transcript + var userNotes: String = "" // User's personal notes added during or after recording + + // Speaker shown in the augmented note view; user-provided or transcriber-derived. + var speaker: String? + + // Blend pipeline outputs (populated post-stop) + var transcript: String? + var transcriptWordsJSON: Data? + var blendedMarkdown: String? + var blendCitationsJSON: Data? + var chaptersJSON: Data? + var blendStatusRaw: String = "idle" + var blendError: String? + var blendCostMicros: Int? + var blendModelVersion: String? + + @Relationship(deleteRule: .cascade, inverse: \Photo.note) var photos: [Photo] = [] + + // Conference grouping. Replaces conferenceName at the read site; + // conferenceName is retained for one release as a fallback. + var conference: Conference? + + var blendStatus: BlendStatus { + get { BlendStatus(rawValue: blendStatusRaw) ?? .idle } + set { blendStatusRaw = newValue.rawValue } + } + + init( + id: UUID = UUID(), + title: String, + content: String = "", + timestamp: Date = Date(), + conferenceName: String? = nil, + sessionType: String = "note", + isArchived: Bool = false, + audioFilePath: String? = nil, + transcriptionStatus: String = "none", + duration: TimeInterval? = nil, + imagePaths: [String] = [], + aiSummary: String? = nil, + userNotes: String = "", + speaker: String? = nil, + conference: Conference? = nil + ) { + self.id = id + self.title = title + self.content = content + self.timestamp = timestamp + self.conferenceName = conferenceName + self.sessionType = sessionType + self.isArchived = isArchived + self.audioFilePath = audioFilePath + self.transcriptionStatus = transcriptionStatus + self.duration = duration + self.imagePaths = imagePaths + self.aiSummary = aiSummary + self.userNotes = userNotes + self.speaker = speaker + self.conference = conference + } +``` + +Leave everything below (computed properties) unchanged. + +- [ ] **Step 4: Update `MuesliApp.swift` schema list** + +Edit `src/mobile/Muesli/MuesliApp.swift`. Replace the schema definition at lines 14-17: + +```swift + let schema = Schema([ + Note.self, + Photo.self, + Conference.self, + ]) +``` + +(Just adding `Conference.self`. `ChatThread` and `ChatMessage` get added in Task 3.) + +- [ ] **Step 5: Run tests** + +Run: `./scripts/test.sh unit` +Expected: PASS — all `ConferenceModelTests` and the four new `NoteModelTests` cases pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/mobile/Muesli/Models/Conference.swift src/mobile/Muesli/Models.swift src/mobile/Muesli/MuesliApp.swift src/mobile/MuesliTests/Models/ConferenceModelTests.swift src/mobile/MuesliTests/Models/NoteModelTests.swift +git commit -m "feat(ios): add Conference entity and Note.conference relationship + +Adds a Conference SwiftData entity with name, location, date range, +and description metadata. Note gains a conference relationship and +a speaker field. conferenceName is retained for one release as a +fallback for the migration. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: Create `ChatThread` and `ChatMessage` models + +**Files:** +- Create: `src/mobile/Muesli/Models/ChatThread.swift` +- Create: `src/mobile/Muesli/Models/ChatMessage.swift` +- Test: `src/mobile/MuesliTests/Models/ChatThreadModelTests.swift` + +- [ ] **Step 1: Write the failing tests** + +Create `src/mobile/MuesliTests/Models/ChatThreadModelTests.swift`: + +```swift +// +// ChatThreadModelTests.swift +// MuesliTests +// +// Unit tests for the ChatThread and ChatMessage SwiftData entities. +// + +import Testing +import SwiftData +import Foundation +@testable import Muesli + +@Suite("Chat Thread Model Tests", .tags(.unit)) +struct ChatThreadModelTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test("ChatThread initializes with talk scope") + func chatThreadTalkScope() async throws { + let noteId = UUID() + let thread = ChatThread(scopeKind: .talk, scopeId: noteId) + + #expect(thread.scopeKind == .talk) + #expect(thread.scopeId == noteId) + #expect(thread.messages.isEmpty) + #expect(thread.createdAt.timeIntervalSinceNow < 1) + #expect(thread.updatedAt.timeIntervalSinceNow < 1) + } + + @Test("ChatThread initializes with conference scope") + func chatThreadConferenceScope() async throws { + let confId = UUID() + let thread = ChatThread(scopeKind: .conference, scopeId: confId) + + #expect(thread.scopeKind == .conference) + #expect(thread.scopeId == confId) + } + + @Test("ChatMessage initializes with role and content") + func chatMessageInit() async throws { + let msg = ChatMessage(role: .user, content: "Hello") + + #expect(msg.role == .user) + #expect(msg.content == "Hello") + #expect(msg.citationsJSON == nil) + #expect(msg.thread == nil) + } + + @Test("ChatThread cascade-deletes messages") + func chatThreadCascadeDeletes() async throws { + let container = try makeContainer() + let context = ModelContext(container) + + let thread = ChatThread(scopeKind: .talk, scopeId: UUID()) + let msg1 = ChatMessage(role: .user, content: "Q") + let msg2 = ChatMessage(role: .assistant, content: "A") + thread.messages = [msg1, msg2] + msg1.thread = thread + msg2.thread = thread + + context.insert(thread) + context.insert(msg1) + context.insert(msg2) + try context.save() + + context.delete(thread) + try context.save() + + let remaining = try context.fetch(FetchDescriptor()) + #expect(remaining.isEmpty) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./scripts/test.sh unit` +Expected: FAIL with "Cannot find 'ChatThread' in scope" etc. + +- [ ] **Step 3: Create `ChatThread.swift`** + +Create `src/mobile/Muesli/Models/ChatThread.swift`: + +```swift +// +// ChatThread.swift +// Muesli +// +// SwiftData entity for a chat conversation, scoped to either a talk or a conference. +// + +import Foundation +import SwiftData + +enum ChatScopeKind: String, Codable { + case talk, conference +} + +@Model +final class ChatThread { + var id: UUID + var scopeKindRaw: String + var scopeId: UUID + var createdAt: Date + var updatedAt: Date + + @Relationship(deleteRule: .cascade, inverse: \ChatMessage.thread) + var messages: [ChatMessage] = [] + + var scopeKind: ChatScopeKind { + get { ChatScopeKind(rawValue: scopeKindRaw) ?? .talk } + set { scopeKindRaw = newValue.rawValue } + } + + init( + id: UUID = UUID(), + scopeKind: ChatScopeKind, + scopeId: UUID, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.scopeKindRaw = scopeKind.rawValue + self.scopeId = scopeId + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} +``` + +- [ ] **Step 4: Create `ChatMessage.swift`** + +Create `src/mobile/Muesli/Models/ChatMessage.swift`: + +```swift +// +// ChatMessage.swift +// Muesli +// +// SwiftData entity for a single chat message within a ChatThread. +// + +import Foundation +import SwiftData + +enum ChatRole: String, Codable { + case user, assistant +} + +@Model +final class ChatMessage { + var id: UUID + var roleRaw: String + var content: String + var citationsJSON: Data? + var createdAt: Date + var thread: ChatThread? + + var role: ChatRole { + get { ChatRole(rawValue: roleRaw) ?? .user } + set { roleRaw = newValue.rawValue } + } + + init( + id: UUID = UUID(), + role: ChatRole, + content: String, + citationsJSON: Data? = nil, + createdAt: Date = Date(), + thread: ChatThread? = nil + ) { + self.id = id + self.roleRaw = role.rawValue + self.content = content + self.citationsJSON = citationsJSON + self.createdAt = createdAt + self.thread = thread + } +} +``` + +- [ ] **Step 5: Register new entities in `MuesliApp.swift`** + +Edit `src/mobile/Muesli/MuesliApp.swift`. Update the schema definition: + +```swift + let schema = Schema([ + Note.self, + Photo.self, + Conference.self, + ChatThread.self, + ChatMessage.self, + ]) +``` + +- [ ] **Step 6: Run tests** + +Run: `./scripts/test.sh unit` +Expected: PASS — all four `ChatThreadModelTests` cases green. + +- [ ] **Step 7: Commit** + +```bash +git add src/mobile/Muesli/Models/ChatThread.swift src/mobile/Muesli/Models/ChatMessage.swift src/mobile/Muesli/MuesliApp.swift src/mobile/MuesliTests/Models/ChatThreadModelTests.swift +git commit -m "feat(ios): add ChatThread and ChatMessage SwiftData entities + +Adds client-side persistence for chat threads scoped to either a talk +(Note) or a conference. Messages cascade-delete with their thread. +Backend remains stateless; iOS retains conversation history. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Implement `ConferenceMigration` + +**Files:** +- Create: `src/mobile/Muesli/Migration/ConferenceMigration.swift` +- Test: `src/mobile/MuesliTests/Models/ConferenceMigrationTests.swift` + +- [ ] **Step 1: Write the failing tests** + +Create `src/mobile/MuesliTests/Models/ConferenceMigrationTests.swift` (uses XCTest to mirror `PhotoMigrationTests`): + +```swift +// +// ConferenceMigrationTests.swift +// MuesliTests +// +// Tests the one-time backfill from Note.conferenceName strings into +// Conference records with attached note relationships. +// + +import XCTest +import SwiftData +@testable import Muesli + +@MainActor +final class ConferenceMigrationTests: XCTestCase { + + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: ConferenceMigration.runFlagKey) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: ConferenceMigration.runFlagKey) + super.tearDown() + } + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + func testGroupsNotesByConferenceName() throws { + let container = try makeContainer() + let context = ModelContext(container) + + let n1 = Note(title: "Talk A", timestamp: Date(timeIntervalSince1970: 1_000), conferenceName: "DataSummit 2026") + let n2 = Note(title: "Talk B", timestamp: Date(timeIntervalSince1970: 2_000), conferenceName: "DataSummit 2026") + let n3 = Note(title: "Solo", timestamp: Date(timeIntervalSince1970: 3_000), conferenceName: "DevWorld") + let n4 = Note(title: "Loose", timestamp: Date(timeIntervalSince1970: 4_000), conferenceName: nil) + [n1, n2, n3, n4].forEach { context.insert($0) } + try context.save() + + ConferenceMigration.run(in: context) + + let confs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(confs.count, 2) + + let summit = confs.first { $0.name == "DataSummit 2026" } + XCTAssertNotNil(summit) + XCTAssertEqual(summit?.notes.count, 2) + XCTAssertEqual(summit?.startDate, Date(timeIntervalSince1970: 1_000)) + XCTAssertEqual(summit?.endDate, Date(timeIntervalSince1970: 2_000)) + + let dev = confs.first { $0.name == "DevWorld" } + XCTAssertEqual(dev?.notes.count, 1) + + // Notes with nil conferenceName remain unattached. + XCTAssertNil(n4.conference) + } + + func testIsIdempotent() throws { + let container = try makeContainer() + let context = ModelContext(container) + + let n1 = Note(title: "A", timestamp: Date(timeIntervalSince1970: 1_000), conferenceName: "DataSummit 2026") + context.insert(n1) + try context.save() + + ConferenceMigration.run(in: context) + ConferenceMigration.run(in: context) + + let confs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(confs.count, 1, "Running migration twice must not create duplicates") + XCTAssertEqual(confs.first?.notes.count, 1) + } + + func testCaseInsensitiveAndTrimmedGrouping() throws { + let container = try makeContainer() + let context = ModelContext(container) + + let a = Note(title: "A", conferenceName: "DataSummit 2026") + let b = Note(title: "B", conferenceName: "datasummit 2026") + let c = Note(title: "C", conferenceName: " DataSummit 2026 ") + [a, b, c].forEach { context.insert($0) } + try context.save() + + ConferenceMigration.run(in: context) + + let confs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(confs.count, 1, "Names differing only by case or whitespace must group") + XCTAssertEqual(confs.first?.notes.count, 3) + } + + func testSkipsNotesAlreadyAttached() throws { + let container = try makeContainer() + let context = ModelContext(container) + + let existing = Conference(name: "DataSummit 2026") + let n = Note(title: "Pre-attached", conferenceName: "DataSummit 2026") + n.conference = existing + context.insert(existing) + context.insert(n) + try context.save() + + ConferenceMigration.run(in: context) + + let confs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(confs.count, 1, "Existing Conference must be reused, not duplicated") + XCTAssertEqual(confs.first?.notes.count, 1) + } + + func testHasRunFlagSet() throws { + let container = try makeContainer() + let context = ModelContext(container) + XCTAssertFalse(ConferenceMigration.hasRun) + ConferenceMigration.run(in: context) + XCTAssertTrue(ConferenceMigration.hasRun) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `./scripts/test.sh unit` +Expected: FAIL with "Cannot find 'ConferenceMigration' in scope". + +- [ ] **Step 3: Implement `ConferenceMigration`** + +Create `src/mobile/Muesli/Migration/ConferenceMigration.swift`: + +```swift +// +// ConferenceMigration.swift +// Muesli +// +// One-time migration that backfills Conference records by grouping +// existing Notes on their legacy `conferenceName` string. Idempotent: +// guarded by a UserDefaults flag, and reuses any pre-existing Conference +// with a matching normalized name. +// + +import Foundation +import SwiftData + +enum ConferenceMigration { + static let runFlagKey = "muesli.conferenceMigration.v1.complete" + + /// Groups notes by `conferenceName` (case-insensitive, whitespace-trimmed) + /// and attaches them to a find-or-created `Conference`. Backfills the + /// conference's startDate/endDate from the min/max note timestamps. + /// Idempotent: safe to call multiple times. + static func run(in context: ModelContext) { + // Skip notes that already have a conference relationship. + let unattached = (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.conference == nil && $0.conferenceName != nil }) + )) ?? [] + + // Group notes by normalized name. + var groups: [String: (display: String, notes: [Note])] = [:] + for note in unattached { + guard let raw = note.conferenceName else { continue } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let key = trimmed.lowercased() + if groups[key] == nil { + groups[key] = (display: trimmed, notes: []) + } + groups[key]?.notes.append(note) + } + + // Find-or-create a Conference per group. + let existing = (try? context.fetch(FetchDescriptor())) ?? [] + var byKey: [String: Conference] = [:] + for conf in existing { + let key = conf.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + byKey[key] = conf + } + + for (key, group) in groups { + let conf: Conference + if let found = byKey[key] { + conf = found + } else { + conf = Conference(name: group.display) + context.insert(conf) + byKey[key] = conf + } + + // Attach notes and refresh date range. group.notes is filtered to + // `conference == nil`, so there's no overlap with conf.notes. + for note in group.notes { + note.conference = conf + } + let timestamps = (conf.notes + group.notes).map(\.timestamp) + conf.startDate = timestamps.min() ?? conf.startDate + conf.endDate = timestamps.max() ?? conf.endDate + } + + try? context.save() + UserDefaults.standard.set(true, forKey: runFlagKey) + } + + static var hasRun: Bool { + UserDefaults.standard.bool(forKey: runFlagKey) + } +} +``` + +- [ ] **Step 4: Run tests** + +Run: `./scripts/test.sh unit` +Expected: PASS — all five `ConferenceMigrationTests` cases green. + +- [ ] **Step 5: Commit** + +```bash +git add src/mobile/Muesli/Migration/ConferenceMigration.swift src/mobile/MuesliTests/Models/ConferenceMigrationTests.swift +git commit -m "feat(ios): add ConferenceMigration to backfill Conference records + +One-time idempotent migration that groups existing notes by their +conferenceName string (case-insensitive, whitespace-trimmed) and +attaches them to a find-or-created Conference. Backfills conference +startDate/endDate from note timestamps. Guarded by a UserDefaults +flag so it does not re-run. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: Wire migration into app launch + +**Files:** +- Modify: `src/mobile/Muesli/MuesliApp.swift` + +- [ ] **Step 1: Add migration trigger to `MuesliApp.init`** + +Edit `src/mobile/Muesli/MuesliApp.swift`. After the existing `PhotoMigration` block, add: + +```swift + if !ConferenceMigration.hasRun { + let context = ModelContext(sharedModelContainer) + ConferenceMigration.run(in: context) + } +``` + +The full `init()` after this change reads: + +```swift + init() { + TranscriptionOrchestrator.shared.setContainer(sharedModelContainer) + BlendOrchestrator.shared.setContainer(sharedModelContainer) + + if !PhotoMigration.hasRun { + let context = ModelContext(sharedModelContainer) + PhotoMigration.run(in: context, fileBytesProvider: { path in + guard let url = AudioRecordingManager.shared.getRecordingURL(fileName: path) else { return nil } + return try? Data(contentsOf: url) + }) + } + + if !ConferenceMigration.hasRun { + let context = ModelContext(sharedModelContainer) + ConferenceMigration.run(in: context) + } + } +``` + +- [ ] **Step 2: Build the app to verify it still launches** + +Run: `./scripts/build.sh` +Expected: Build succeeds. No runtime test for this step; the launch wiring is exercised in Task 7's smoke check. + +- [ ] **Step 3: Commit** + +```bash +git add src/mobile/Muesli/MuesliApp.swift +git commit -m "feat(ios): run ConferenceMigration on app launch + +Mirrors the PhotoMigration trigger pattern. Migration is gated by a +UserDefaults flag so it runs at most once per install. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Seed conferences in `SampleDataManager` + +**Files:** +- Modify: `src/mobile/Muesli/SampleData/SampleDataManager.swift` + +`SampleDataManager` currently exposes `seedDatabase(context:)` which calls `generateSampleNotes() -> [Note]` and inserts the result. We will refactor so the conferences are created first inside `seedDatabase`, then passed to `generateSampleNotes(dataSummit:devWorld:)` which attaches each note to a conference (or none). `clearAllData` also needs to delete `Conference`, `ChatThread`, and `ChatMessage` so the new schema is fully reset. + +- [ ] **Step 1: Replace `seedDatabase` and `generateSampleNotes`** + +Edit `src/mobile/Muesli/SampleData/SampleDataManager.swift`. Replace the entire body of `seedDatabase(context:)` (lines 16-29) and the entire `generateSampleNotes()` function (lines 31-111) with: + +```swift + static func seedDatabase(context: ModelContext) { + let conferences = generateSampleConferences() + conferences.forEach(context.insert) + + let dataSummit = conferences[0] + let devWorld = conferences[1] + let sampleNotes = generateSampleNotes(dataSummit: dataSummit, devWorld: devWorld) + + for note in sampleNotes { + context.insert(note) + } + + do { + try context.save() + AppLogger.shared.dataSuccess( + "Sample Data", + details: "Seeded \(conferences.count) conferences and \(sampleNotes.count) notes" + ) + } catch { + AppLogger.shared.dataError("Sample Data", error: error) + } + } + + static func generateSampleConferences() -> [Conference] { + let cal = Calendar.current + let dataSummit = Conference( + name: "DataSummit 2026", + location: "San Francisco, CA", + startDate: cal.date(from: DateComponents(year: 2026, month: 5, day: 10)), + endDate: cal.date(from: DateComponents(year: 2026, month: 5, day: 12)), + conferenceDescription: "Annual data and ML conference" + ) + let devWorld = Conference( + name: "DevWorld 2026", + location: "Austin, TX", + startDate: cal.date(from: DateComponents(year: 2026, month: 3, day: 14)), + endDate: cal.date(from: DateComponents(year: 2026, month: 3, day: 16)), + conferenceDescription: "Developer conference covering web, mobile, and platforms" + ) + return [dataSummit, devWorld] + } + + static func generateSampleNotes(dataSummit: Conference, devWorld: Conference) -> [Note] { + let baseTime = Date() + + return [ + // DataSummit 2026 talks (3) + Note( + title: "The three pillars of data infra", + content: "Storage, compute, and discoverability. Sarah walked through how DataSummit's flagship team rebuilt their lake-house on these primitives.", + timestamp: baseTime.addingTimeInterval(-3600), + conferenceName: "DataSummit 2026", + sessionType: "session", + isArchived: false, + audioFilePath: "sample_three_pillars.m4a", + transcriptionStatus: "completed", + duration: 2400, + speaker: "Sarah Chen", + conference: dataSummit + ), + Note( + title: "Streaming at planet scale", + content: "Devon's deep dive on multi-region streaming, exactly-once semantics, and the operational realities they hit at year three.", + timestamp: baseTime.addingTimeInterval(-7200), + conferenceName: "DataSummit 2026", + sessionType: "session", + isArchived: false, + audioFilePath: "sample_streaming.m4a", + transcriptionStatus: "completed", + duration: 2700, + speaker: "Devon Park", + conference: dataSummit + ), + Note( + title: "Embeddings for everything", + content: "Hina's plenary on using embeddings as the universal interface across retrieval, ranking, and dedup.", + timestamp: baseTime.addingTimeInterval(-90000), + conferenceName: "DataSummit 2026", + sessionType: "session", + isArchived: false, + audioFilePath: "sample_embeddings.m4a", + transcriptionStatus: "completed", + duration: 3000, + speaker: "Hina Yoshida", + conference: dataSummit + ), + + // DevWorld 2026 talks (2) + Note( + title: "SwiftUI performance audit", + content: "A pragmatic tour of Instruments for SwiftUI, view identity, and the diff cost of large lists.", + timestamp: baseTime.addingTimeInterval(-5_184_000), + conferenceName: "DevWorld 2026", + sessionType: "session", + isArchived: false, + audioFilePath: "sample_swiftui_perf.m4a", + transcriptionStatus: "completed", + duration: 1800, + speaker: "Aiden Reyes", + conference: devWorld + ), + Note( + title: "Edge runtimes in practice", + content: "What works, what doesn't, and the boring middle of running production services at the edge.", + timestamp: baseTime.addingTimeInterval(-5_270_400), + conferenceName: "DevWorld 2026", + sessionType: "session", + isArchived: false, + audioFilePath: nil, + transcriptionStatus: "none", + duration: 0, + speaker: "Priya Iyer", + conference: devWorld + ), + + // Ungrouped notes (preserved for non-conference flows) + Note( + title: "Team Standup", + content: "Discussed current sprint progress. John is working on the API integration, Sarah is finishing the UI components.", + timestamp: baseTime.addingTimeInterval(-1800), + conferenceName: nil, + sessionType: "meeting", + isArchived: false, + audioFilePath: nil, + transcriptionStatus: "none", + duration: 0 + ), + Note( + title: "Old Project Notes", + content: "Legacy project documentation that's no longer active but kept for reference.", + timestamp: baseTime.addingTimeInterval(-604800), + conferenceName: nil, + sessionType: "documentation", + isArchived: true, + audioFilePath: nil, + transcriptionStatus: "none", + duration: 0 + ) + ] + } +``` + +- [ ] **Step 2: Update `clearAllData` to clear all new entities** + +In the same file, replace the `clearAllData(context:)` body (lines 115-123) with: + +```swift + static func clearAllData(context: ModelContext) { + do { + try context.delete(model: ChatMessage.self) + try context.delete(model: ChatThread.self) + try context.delete(model: Note.self) + try context.delete(model: Conference.self) + try context.save() + AppLogger.shared.dataSuccess("Sample Data", details: "Cleared all data") + } catch { + AppLogger.shared.dataError("Sample Data Clear", error: error) + } + } +``` + +Order matters: delete child rows (`ChatMessage`, then `ChatThread`, then `Note`) before parents (`Conference`). `Photo` deletion happens implicitly via `Note.photos` cascade. + +- [ ] **Step 3: Run unit tests** + +Run: `./scripts/test.sh unit` +Expected: PASS. Existing sample-data validation tests continue to pass; the new conference-attached notes serialize and load correctly. + +- [ ] **Step 4: Commit** + +```bash +git add src/mobile/Muesli/SampleData/SampleDataManager.swift +git commit -m "feat(ios): seed two conferences in sample data + +Refactors seedDatabase to build DataSummit 2026 (3 talks) and +DevWorld 2026 (2 talks) with location, date range, and descriptions. +Each conference talk includes a speaker. Two ungrouped notes are +retained for non-conference flows. clearAllData now deletes the new +ChatMessage/ChatThread/Conference entities in dependency order. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 7: Smoke test the full launch path + +**Files:** none (manual verification) + +- [ ] **Step 1: Build the app** + +Run: `./scripts/build.sh clean` +Expected: Clean build succeeds. + +- [ ] **Step 2: Launch on simulator** + +Run: `./scripts/test.sh all` +Expected: All unit and UI tests pass. SwiftData lightweight migration applies the new entities; existing notes survive; `ConferenceMigration` runs once and groups any preexisting `conferenceName` strings. + +- [ ] **Step 3: Inspect via the debug menu (optional manual check)** + +If `DebugMenuView` exposes a database inspector, launch the app on a simulator and confirm: +- At least one `Conference` record exists if any seed/legacy note had a `conferenceName`. +- `UserDefaults` shows `muesli.conferenceMigration.v1.complete` set to true. + +This step is informational only; failures here mean Task 4 logic is wrong and the migration tests missed a case — add a regression test and fix. + +- [ ] **Step 4: Run lint** + +Run: `./scripts/lint.sh fix` +Expected: No SwiftLint violations introduced. Auto-fix anything that surfaces, re-run, confirm clean. + +- [ ] **Step 5: Final commit if lint touched anything** + +```bash +git add -A +git diff --staged --quiet || git commit -m "chore(ios): lint fixes from phase 1 + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +If lint produced no changes, skip the commit. + +--- + +## Phase 1 done when + +- All seven tasks committed. +- `./scripts/test.sh unit` green. +- `./scripts/build.sh` produces a clean build. +- `./scripts/lint.sh` reports no violations. +- `Conference`, `ChatThread`, `ChatMessage` registered in the model schema. +- `Note.conference` and `Note.speaker` available. +- `ConferenceMigration` runs at launch and is idempotent. + +## Next plan + +Phase 2 covers the chat backend (`chatService.js`, `POST /sessions/:id/chat`, `POST /conferences/:id/chat`, tests). Written after Phase 1 merges into the feature branch. diff --git a/docs/superpowers/plans/2026-05-12-phase-2-chat-backend.md b/docs/superpowers/plans/2026-05-12-phase-2-chat-backend.md new file mode 100644 index 0000000..86953c0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-phase-2-chat-backend.md @@ -0,0 +1,683 @@ +# Phase 2: Chat Backend Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans or superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Add a stateless chat API to the Node backend. One route for talk-scope (single session) chat; one route for multi-session (conference-scope) chat. Each turn includes citations to transcript timestamps or note titles. + +**Architecture:** Mirror the existing `blendService` pattern: a service module (`chatService.js`) wrapping Anthropic with a strict JSON contract, plus thin Express handlers that delegate context assembly to the service and post-process citation references. Stateless — client owns the conversation history. Reuses `requireAuth`, `sessionsRepo`, `ledgerService`, and the JSON multipart/auth middleware already wired in `src/server.js`. + +**Tech Stack:** Node 18+, Express, Anthropic SDK (`@anthropic-ai/sdk`), Jest (ES modules). + +**Spec reference:** `docs/superpowers/specs/2026-05-12-gap-close-design.md` § Chat Backend Design. + +**Route shape (deviation from spec):** +- `POST /v1/sessions/:id/chat` — talk-scope (single session). Per spec. +- `POST /v1/chat` — multi-session. Body carries `sessionIds: []` since the API has no server-side concept of "conference" yet. iOS computes the conference's session list and sends it. This avoids adding a Conference resource on the server until needed. + +--- + +## File Structure + +**Creating:** +- `src/api/src/services/chatService.js` — context assembly + Anthropic call + citation post-processing +- `src/api/src/routes/chat.js` — POST /chat (multi-session) +- `src/api/tests/unit/chatService.test.js` — service unit tests +- `src/api/tests/integration/chat.test.js` — route integration tests + +**Modifying:** +- `src/api/src/routes/sessions.js` — add POST /:id/chat +- `src/api/src/server.js` — mount `chatRouter` under `/v1/chat` + +--- + +## Task 1: `chatService.js` happy path with talk scope + +**Files:** +- Create: `src/api/src/services/chatService.js` +- Test: `src/api/tests/unit/chatService.test.js` + +- [ ] **Step 1: Write the failing test** + +```js +import { describe, it, expect, jest } from '@jest/globals'; +import { chat } from '../../src/services/chatService.js'; + +const okResponse = (answer = 'The talk covered three pillars.', references = []) => ({ + content: [{ type: 'text', text: JSON.stringify({ answer, references }) }], + usage: { input_tokens: 1200, output_tokens: 180 } +}); + +describe('chat (talk scope)', () => { + it('builds context for one session and returns assistant message with empty citations when no references', async () => { + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue(okResponse()) } }; + const result = await chat({ + scope: { kind: 'talk', sessionId: 'sess-1' }, + messages: [{ role: 'user', content: 'What did Sarah say?' }], + sessions: [{ id: 'sess-1', title: 'Three pillars', speaker: 'Sarah Chen', transcript: 'Sarah said hello.', blendedMarkdown: 'Hello.', photos: [], aiSummary: 'A talk.' }], + }, { anthropic: fakeAnthropic }); + expect(result.message.role).toBe('assistant'); + expect(result.message.content).toBe('The talk covered three pillars.'); + expect(result.citations).toEqual([]); + expect(result.tokensIn).toBe(1200); + expect(result.tokensOut).toBe(180); + expect(fakeAnthropic.messages.create).toHaveBeenCalledTimes(1); + }); +}); +``` + +- [ ] **Step 2: Run test, verify FAIL** + +Run: `cd src/api && npm test -- chatService` +Expected: FAIL — file does not exist. + +- [ ] **Step 3: Implement `chatService.js` (minimal)** + +```js +import { anthropic, SONNET_MODEL } from './anthropic.js'; + +const SYSTEM = `You are a helpful assistant answering questions about conference talks. + +Rules: +1. Answer only from the supplied context. If you don't know, say so plainly. +2. Inline citation tokens [[c:N]] reference the N-th entry of a parallel "references" array you also return. +3. Return JSON only: { "answer": "...", "references": [ { "kind": "transcript" | "note", "sessionId": "...", "startSec": 0.0?, "endSec": 0.0? } ] } + - "transcript" references include startSec and endSec. + - "note" references include only sessionId. +4. No prose outside the JSON.`; + +const REQUIRED_FIELDS = ['answer', 'references']; + +function buildContext(sessions) { + return sessions.map(s => { + const photoBlurb = (s.photos ?? []).map(p => `- photo ${p.photoId}: ocr="${p.ocrText ?? ''}"; desc="${p.description ?? ''}"`).join('\n'); + return `## Session ${s.id} — ${s.title ?? '(untitled)'} +Speaker: ${s.speaker ?? '(unknown)'} +Summary: ${s.aiSummary ?? '(none)'} +Transcript: +${s.transcript ?? '(no transcript)'} +Blended notes: +${s.blendedMarkdown ?? '(none)'} +Photos: +${photoBlurb || '(none)'}`; + }).join('\n\n'); +} + +function stripCitationTokens(answer) { + return answer.replace(/\[\[c:\d+\]\]/g, '').replace(/\s+/g, ' ').trim(); +} + +function resolveCitations(references, sessions) { + const byId = new Map(sessions.map(s => [s.id, s])); + const out = []; + for (const r of references) { + const s = byId.get(r.sessionId); + if (!s) continue; + if (r.kind === 'transcript' && typeof r.startSec === 'number' && typeof r.endSec === 'number') { + const mm = Math.floor(r.startSec / 60).toString().padStart(2, '0'); + const ss = Math.floor(r.startSec % 60).toString().padStart(2, '0'); + out.push({ + kind: 'transcript', + talkId: r.sessionId, + startSec: r.startSec, + endSec: r.endSec, + label: `${mm}:${ss}` + }); + } else if (r.kind === 'note') { + out.push({ kind: 'note', noteId: r.sessionId, title: s.title ?? '' }); + } + } + return out; +} + +export async function chat({ scope, messages, sessions }, deps = {}) { + const client = deps.anthropic ?? anthropic; + const context = buildContext(sessions); + + const userMessage = `Context:\n${context}\n\nConversation so far:\n${messages.map(m => `${m.role}: ${m.content}`).join('\n')}`; + + const response = await client.messages.create({ + model: SONNET_MODEL, + max_tokens: 2000, + system: SYSTEM, + messages: [{ role: 'user', content: userMessage }] + }); + + const raw = response.content?.[0]?.text; + if (!raw) throw new Error('Empty response from Sonnet'); + + let parsed; + try { parsed = JSON.parse(raw); } + catch { throw new Error(`Sonnet returned invalid JSON: ${raw.slice(0, 200)}`); } + + for (const f of REQUIRED_FIELDS) { + if (!(f in parsed)) throw new Error(`Sonnet output missing required field: ${f}`); + } + + const message = { role: 'assistant', content: stripCitationTokens(parsed.answer) }; + const citations = resolveCitations(parsed.references, sessions); + + return { + message, + citations, + tokensIn: response.usage?.input_tokens ?? 0, + tokensOut: response.usage?.output_tokens ?? 0, + }; +} +``` + +- [ ] **Step 4: Run test, verify PASS** + +Run: `cd src/api && npm test -- chatService` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/api/src/services/chatService.js src/api/tests/unit/chatService.test.js +git commit -m "feat(api): add chatService for talk + conference scopes + +Builds Anthropic-backed chat that answers from supplied session +context. Strict JSON contract with parallel references array; +[[c:N]] tokens are stripped from the user-facing answer and the +references are post-processed into citations carrying display labels +(mm:ss for transcript, note title for note). + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: chatService — citation resolution and JSON failure modes + +**Files:** +- Test: `src/api/tests/unit/chatService.test.js` (extend) + +- [ ] **Step 1: Add tests** + +```js +it('strips [[c:N]] tokens and resolves transcript citations to mm:ss labels', async () => { + const okWithCites = { + content: [{ type: 'text', text: JSON.stringify({ + answer: 'Sarah opened with evals [[c:0]] and the three pillars [[c:1]].', + references: [ + { kind: 'transcript', sessionId: 'sess-1', startSec: 12.4, endSec: 24.1 }, + { kind: 'note', sessionId: 'sess-1' } + ] + }) }], + usage: { input_tokens: 1000, output_tokens: 60 } + }; + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue(okWithCites) } }; + const r = await chat({ + scope: { kind: 'talk', sessionId: 'sess-1' }, + messages: [{ role: 'user', content: 'summarize' }], + sessions: [{ id: 'sess-1', title: 'Three pillars', transcript: 't', photos: [] }], + }, { anthropic: fakeAnthropic }); + expect(r.message.content).not.toMatch(/\[\[c:/); + expect(r.citations).toHaveLength(2); + expect(r.citations[0]).toMatchObject({ kind: 'transcript', talkId: 'sess-1', label: '00:12' }); + expect(r.citations[1]).toMatchObject({ kind: 'note', noteId: 'sess-1', title: 'Three pillars' }); +}); + +it('drops references whose sessionId is not in scope', async () => { + const respWithDangling = { + content: [{ type: 'text', text: JSON.stringify({ + answer: 'See [[c:0]] and [[c:1]].', + references: [ + { kind: 'transcript', sessionId: 'sess-1', startSec: 0, endSec: 5 }, + { kind: 'transcript', sessionId: 'sess-MISSING', startSec: 10, endSec: 12 } + ] + }) }], + usage: { input_tokens: 1, output_tokens: 1 } + }; + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue(respWithDangling) } }; + const r = await chat({ + scope: { kind: 'talk', sessionId: 'sess-1' }, + messages: [{ role: 'user', content: 'q' }], + sessions: [{ id: 'sess-1', title: 'T', transcript: 't', photos: [] }], + }, { anthropic: fakeAnthropic }); + expect(r.citations).toHaveLength(1); + expect(r.citations[0].talkId).toBe('sess-1'); +}); + +it('throws on invalid JSON', async () => { + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue({ content: [{ type: 'text', text: 'not json' }], usage: { input_tokens: 1, output_tokens: 1 } }) } }; + await expect(chat({ scope: { kind: 'talk', sessionId: 's' }, messages: [], sessions: [{ id: 's', transcript: '', photos: [] }] }, { anthropic: fakeAnthropic })) + .rejects.toThrow(/JSON/); +}); + +it('throws on missing required field', async () => { + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue({ content: [{ type: 'text', text: JSON.stringify({ answer: 'x' }) }], usage: { input_tokens: 1, output_tokens: 1 } }) } }; + await expect(chat({ scope: { kind: 'talk', sessionId: 's' }, messages: [], sessions: [{ id: 's', transcript: '', photos: [] }] }, { anthropic: fakeAnthropic })) + .rejects.toThrow(/references/); +}); +``` + +- [ ] **Step 2: Run, expect PASS** (implementation from Task 1 already handles these cases) + +Run: `cd src/api && npm test -- chatService` + +- [ ] **Step 3: Commit** + +```bash +git add src/api/tests/unit/chatService.test.js +git commit -m "test(api): chatService citation handling and JSON guards + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: chatService — conference-scope context fits within budget + +**Files:** +- Modify: `src/api/src/services/chatService.js` +- Test: `src/api/tests/unit/chatService.test.js` + +- [ ] **Step 1: Add tests for token budget heuristic** + +```js +it('for conference scope keeps full blends only for the N most recent sessions and summarizes the rest', async () => { + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue({ content: [{ type: 'text', text: JSON.stringify({ answer: 'ok', references: [] }) }], usage: { input_tokens: 1, output_tokens: 1 } }) } }; + const longBlend = 'X'.repeat(50000); + const sessions = Array.from({ length: 6 }, (_, i) => ({ + id: `sess-${i}`, + title: `Talk ${i}`, + transcript: 't', + blendedMarkdown: longBlend, + aiSummary: `summary ${i}`, + createdAt: new Date(2026, 0, i + 1).toISOString(), + photos: [] + })); + await chat({ + scope: { kind: 'conference', sessionIds: sessions.map(s => s.id) }, + messages: [{ role: 'user', content: 'q' }], + sessions + }, { anthropic: fakeAnthropic }); + const call = fakeAnthropic.messages.create.mock.calls[0][0]; + const userContent = call.messages[0].content; + // The 3 most recent sessions should have full blends; older ones summary-only. + expect(userContent).toContain('Talk 5'); + expect(userContent).toContain('Talk 4'); + expect(userContent).toContain('Talk 3'); + // Older talks appear by summary, not full blend body. + expect(userContent).toContain('summary 0'); + // Length cap: should not blow past 150k tokens of input. Rough heuristic: < 200k chars. + expect(userContent.length).toBeLessThan(200_000); +}); +``` + +- [ ] **Step 2: Run, verify FAIL** (current implementation includes all blends) + +Run: `cd src/api && npm test -- chatService` + +- [ ] **Step 3: Update buildContext to handle conference scope** + +In `chatService.js`, replace `buildContext(sessions)` with a scope-aware version: + +```js +const FULL_BLEND_RECENT_N = 3; + +function compactSession(s) { + return `## Session ${s.id} — ${s.title ?? '(untitled)'} +Speaker: ${s.speaker ?? '(unknown)'} +Summary: ${s.aiSummary ?? '(none)'}`; +} + +function fullSession(s) { + const photoBlurb = (s.photos ?? []).map(p => `- photo ${p.photoId}: ocr="${p.ocrText ?? ''}"; desc="${p.description ?? ''}"`).join('\n'); + return `## Session ${s.id} — ${s.title ?? '(untitled)'} +Speaker: ${s.speaker ?? '(unknown)'} +Summary: ${s.aiSummary ?? '(none)'} +Transcript: +${s.transcript ?? '(no transcript)'} +Blended notes: +${s.blendedMarkdown ?? '(none)'} +Photos: +${photoBlurb || '(none)'}`; +} + +function buildContext(scope, sessions) { + if (scope.kind === 'talk') { + return sessions.map(fullSession).join('\n\n'); + } + // conference: full blends only for the N most recent + const sorted = [...sessions].sort((a, b) => + (new Date(b.createdAt ?? 0)).getTime() - (new Date(a.createdAt ?? 0)).getTime() + ); + const recent = new Set(sorted.slice(0, FULL_BLEND_RECENT_N).map(s => s.id)); + return sessions.map(s => recent.has(s.id) ? fullSession(s) : compactSession(s)).join('\n\n'); +} +``` + +And change the call site `chat()` to pass scope: + +```js +const context = buildContext(scope, sessions); +``` + +- [ ] **Step 4: Run, verify PASS** + +Run: `cd src/api && npm test -- chatService` + +- [ ] **Step 5: Commit** + +```bash +git add src/api/src/services/chatService.js src/api/tests/unit/chatService.test.js +git commit -m "feat(api): conference-scope context heuristic in chatService + +Full blends for the 3 most-recent sessions; older sessions degrade +to title + speaker + summary only. Keeps the corpus within the +~150k input-token budget for Sonnet. Embedding-based retrieval is a +future improvement. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: `POST /v1/sessions/:id/chat` route (talk scope) + +**Files:** +- Modify: `src/api/src/routes/sessions.js` +- Test: `src/api/tests/integration/chat.test.js` + +- [ ] **Step 1: Write the failing integration test** + +Create `src/api/tests/integration/chat.test.js`: + +```js +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import request from 'supertest'; +import { sessionsRepo } from '../../src/services/sessionsRepo.js'; + +// Mock Anthropic BEFORE importing the app so chatService picks up the mock. +jest.unstable_mockModule('../../src/services/anthropic.js', () => ({ + anthropic: { messages: { create: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ answer: 'mocked answer', references: [] }) }], + usage: { input_tokens: 100, output_tokens: 20 } + }) } }, + SONNET_MODEL: 'claude-sonnet-4-6', + HAIKU_MODEL: 'claude-haiku-4-5-20251001' +})); + +const { default: app } = await import('../../src/server.js'); + +describe('POST /v1/sessions/:id/chat', () => { + let sessionId; + beforeEach(async () => { + sessionId = await sessionsRepo.createSession({ userId: 'local-dev' }); + await sessionsRepo.saveTranscript(sessionId, { text: 'Sarah said hello.', words: [] }); + }); + + it('returns the assistant message and empty citations on a fresh session', async () => { + const res = await request(app) + .post(`/v1/sessions/${sessionId}/chat`) + .send({ messages: [{ role: 'user', content: 'What did Sarah say?' }] }); + + expect(res.status).toBe(200); + expect(res.body.message.role).toBe('assistant'); + expect(res.body.message.content).toBe('mocked answer'); + expect(res.body.citations).toEqual([]); + expect(res.body.usage.tokensIn).toBe(100); + }); + + it('404s for unknown session', async () => { + const res = await request(app) + .post('/v1/sessions/00000000-0000-0000-0000-000000000000/chat') + .send({ messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(404); + }); + + it('400s when messages is missing', async () => { + const res = await request(app) + .post(`/v1/sessions/${sessionId}/chat`) + .send({}); + expect(res.status).toBe(400); + }); +}); +``` + +- [ ] **Step 2: Run, verify FAIL** + +Run: `cd src/api && npm test -- chat.test` +Expected: FAIL — route doesn't exist. + +- [ ] **Step 3: Add the route to `sessions.js`** + +Append below the existing `/:id/blend` handler: + +```js +import { chat } from '../services/chatService.js'; + +router.post('/:id/chat', express.json(), async (req, res) => { + const id = req.params.id; + const s = await sessionsRepo.getSession(id); + if (!s) return res.status(404).json({ error: 'session_not_found' }); + const messages = req.body?.messages; + if (!Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ error: 'messages_required' }); + } + try { + const result = await chat({ + scope: { kind: 'talk', sessionId: id }, + messages, + sessions: [{ + id: s.id, title: null, speaker: null, transcript: s.transcript, + blendedMarkdown: s.blendedMarkdown, aiSummary: null, + photos: s.photos.filter(p => p.extractStatus === 'complete'), + createdAt: s.createdAt + }] + }); + res.json({ + message: result.message, + citations: result.citations, + usage: { tokensIn: result.tokensIn, tokensOut: result.tokensOut } + }); + } catch (e) { + Logger.error('chat (talk) failed', e); + res.status(502).json({ error: 'chat_failed', detail: e.message }); + } +}); +``` + +(The existing `import` block at the top of `sessions.js` already brings in `Logger` etc; add the `chat` import near the others.) + +- [ ] **Step 4: Run, verify PASS** + +Run: `cd src/api && npm test -- chat.test` + +- [ ] **Step 5: Commit** + +```bash +git add src/api/src/routes/sessions.js src/api/tests/integration/chat.test.js +git commit -m "feat(api): POST /v1/sessions/:id/chat (talk-scope chat) + +Adds a stateless chat endpoint scoped to a single session. Client +provides the full conversation each turn. Server fetches the +session, assembles context, calls chatService, and returns +{ message, citations, usage }. 404 for unknown session, 400 for +missing messages, 502 if Sonnet fails. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 5: `POST /v1/chat` route (multi-session / conference scope) + +**Files:** +- Create: `src/api/src/routes/chat.js` +- Modify: `src/api/src/server.js` (mount router) +- Test: `src/api/tests/integration/chat.test.js` (extend) + +- [ ] **Step 1: Add the failing integration test** + +Append to `tests/integration/chat.test.js`: + +```js +describe('POST /v1/chat (multi-session scope)', () => { + let sess1, sess2; + beforeEach(async () => { + sess1 = await sessionsRepo.createSession({ userId: 'local-dev' }); + sess2 = await sessionsRepo.createSession({ userId: 'local-dev' }); + await sessionsRepo.saveTranscript(sess1, { text: 'talk one', words: [] }); + await sessionsRepo.saveTranscript(sess2, { text: 'talk two', words: [] }); + }); + + it('aggregates two sessions and returns assistant message', async () => { + const res = await request(app) + .post('/v1/chat') + .send({ sessionIds: [sess1, sess2], messages: [{ role: 'user', content: 'across talks' }] }); + expect(res.status).toBe(200); + expect(res.body.message.role).toBe('assistant'); + }); + + it('400s when sessionIds is missing or empty', async () => { + const res = await request(app) + .post('/v1/chat') + .send({ messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(400); + }); + + it('404s when any session is unknown', async () => { + const res = await request(app) + .post('/v1/chat') + .send({ sessionIds: [sess1, '00000000-0000-0000-0000-000000000000'], messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(404); + }); +}); +``` + +- [ ] **Step 2: Run, expect FAIL** + +Run: `cd src/api && npm test -- chat.test` + +- [ ] **Step 3: Create `src/api/src/routes/chat.js`** + +```js +import express from 'express'; +import { sessionsRepo } from '../services/sessionsRepo.js'; +import { chat } from '../services/chatService.js'; +import Logger from '../utils/logger.js'; + +const router = express.Router(); + +router.post('/', express.json(), async (req, res) => { + const sessionIds = req.body?.sessionIds; + const messages = req.body?.messages; + if (!Array.isArray(sessionIds) || sessionIds.length === 0) { + return res.status(400).json({ error: 'sessionIds_required' }); + } + if (!Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ error: 'messages_required' }); + } + + const sessions = []; + for (const id of sessionIds) { + const s = await sessionsRepo.getSession(id); + if (!s) return res.status(404).json({ error: 'session_not_found', sessionId: id }); + sessions.push({ + id: s.id, title: null, speaker: null, + transcript: s.transcript, blendedMarkdown: s.blendedMarkdown, + aiSummary: null, + photos: s.photos.filter(p => p.extractStatus === 'complete'), + createdAt: s.createdAt + }); + } + + try { + const result = await chat({ + scope: { kind: 'conference', sessionIds }, + messages, + sessions + }); + res.json({ + message: result.message, + citations: result.citations, + usage: { tokensIn: result.tokensIn, tokensOut: result.tokensOut } + }); + } catch (e) { + Logger.error('chat (multi-session) failed', e); + res.status(502).json({ error: 'chat_failed', detail: e.message }); + } +}); + +export default router; +``` + +- [ ] **Step 4: Mount the router in `src/server.js`** + +Find the routes-mounting block (look for `app.use('/v1/sessions', ...)`) and add: + +```js +import chatRouter from './routes/chat.js'; +// ... later ... +app.use('/v1/chat', requireAuth, chatRouter); +``` + +- [ ] **Step 5: Run tests, verify PASS** + +Run: `cd src/api && npm test -- chat.test` + +- [ ] **Step 6: Commit** + +```bash +git add src/api/src/routes/chat.js src/api/src/server.js src/api/tests/integration/chat.test.js +git commit -m "feat(api): POST /v1/chat for multi-session (conference) chat + +Body carries sessionIds (the iOS client computes the membership +from its Conference relationship). Server fetches each session, +builds aggregated context via chatService, and returns the same +shape as the talk-scope route. 400 on missing sessionIds or +messages, 404 when any sessionId is unknown. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 6: Coverage gate stays green + +- [ ] **Step 1: Run full API suite with coverage** + +Run: `cd src/api && npm test` +Expected: all tests pass; coverage at or above 70% lines/statements (per CI gate from commit 3a5a0b9). + +- [ ] **Step 2: If coverage dipped, add a coverage-fortifying test** + +The most likely uncovered branches are the citation post-processing edge cases (already covered) and the error paths in the routes. If coverage drops, add an integration test for the 502 path: + +```js +it('502s when chatService throws', async () => { + // jest module mock applied at top of file already; override once for this test: + const { anthropic } = await import('../../src/services/anthropic.js'); + anthropic.messages.create.mockRejectedValueOnce(new Error('Sonnet down')); + const res = await request(app) + .post(`/v1/sessions/${sessionId}/chat`) + .send({ messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(502); +}); +``` + +- [ ] **Step 3: Commit any added coverage tests** + +```bash +git add src/api/tests/integration/chat.test.js +git commit -m "test(api): cover chat 502 path + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 2 done when + +- All six tasks committed. +- `cd src/api && npm test` green. +- Coverage at or above 70% lines/statements. +- Both chat routes return `{ message, citations, usage }` for happy paths and the documented error codes for failures. +- No real network call to Anthropic during tests (everything mocked). + +## Next plan + +Phase 3 covers the augmented-note renderer + AugmentedNoteView on iOS (the flagship view). diff --git a/docs/superpowers/plans/2026-05-12-phase-3-blend-renderer.md b/docs/superpowers/plans/2026-05-12-phase-3-blend-renderer.md new file mode 100644 index 0000000..8d72246 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-phase-3-blend-renderer.md @@ -0,0 +1,670 @@ +# Phase 3: BlendRenderer + AugmentedNoteView Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans or superpowers:subagent-driven-development. + +**Goal:** Render `Note.blendedMarkdown` + the parallel `BlendCitations` (`userNoteSpans`, `quoteSpans`, `imagePlacements`, `citations`) + photos as a styled SwiftUI segment list. Add `AugmentedNoteView` that consumes the renderer for the augmented-note screen. + +**Architecture:** A pure `BlendRenderer` value type that returns `[BlendSegment]`. Segments alternate between styled `AttributedString` text and `Photo` cards. The view iterates the segment list with `ForEach`. No SwiftUI markdown parsing in v1 — we render the raw text as `AttributedString` and apply char-range overlays directly, because the blend service emits char offsets into the raw markdown source and any markdown parser would shift those indices. Bold/italic gain is forfeited; user-note highlighting and quote/citation styling are preserved. + +**Tech Stack:** SwiftUI, Swift Testing (`import Testing`), `AttributedString`. + +**Spec reference:** `docs/superpowers/specs/2026-05-12-gap-close-design.md` § Component design: Augmented note renderer. + +**Deferred to later phases:** +- Tap on `quoteSpans` / `citations` opening `ChapteredPlaybackView` (Phase 5 wires the scrubber) +- Edit affordances (AI-summary sheet, inline userNotes editor) — Phase 9 salvage +- Replacing `SimpleNoteDetailView` navigation everywhere — Phase 9 +- Markdown formatting (bold/italic from the blend service's `**...**` etc.) — future polish + +--- + +## File Structure + +**Creating:** +- `src/mobile/Muesli/Views/AugmentedNoteView.swift` — flagship screen +- `src/mobile/Muesli/Views/Components/BlendRenderer.swift` — pure renderer + segment type +- `src/mobile/Muesli/Views/Components/SlideCard.swift` — photo card used inside segments +- `src/mobile/MuesliTests/Views/BlendRendererTests.swift` — renderer unit tests + +--- + +## Task 1: `BlendSegment` + `BlendRenderer` skeleton + +**Files:** +- Create: `src/mobile/Muesli/Views/Components/BlendRenderer.swift` +- Create: `src/mobile/MuesliTests/Views/BlendRendererTests.swift` + +- [ ] **Step 1: Write the first failing test** + +```swift +// +// BlendRendererTests.swift +// MuesliTests +// + +import Testing +import Foundation +import SwiftData +@testable import Muesli + +@Suite("Blend Renderer Tests", .tags(.unit)) +struct BlendRendererTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test("Renderer returns empty segments when blendedMarkdown is nil") + @MainActor + func emptyWhenNoMarkdown() async throws { + let container = try makeContainer() + let note = Note(title: "x") + container.mainContext.insert(note) + let segments = BlendRenderer.render(note: note) + #expect(segments.isEmpty) + } + + @Test("Renderer returns a single text segment when no overlays or photos") + @MainActor + func singleTextSegment() async throws { + let container = try makeContainer() + let note = Note(title: "x") + note.blendedMarkdown = "Just plain prose." + container.mainContext.insert(note) + let segments = BlendRenderer.render(note: note) + #expect(segments.count == 1) + guard case .text(let attr) = segments[0] else { + Issue.record("Expected .text segment") + return + } + #expect(String(attr.characters) == "Just plain prose.") + } +} +``` + +- [ ] **Step 2: Run, expect FAIL (no BlendRenderer yet)** + +Focused run: +``` +cd src/mobile && xcodebuild test -scheme Muesli -destination "platform=iOS Simulator,name=iPhone 17,OS=26.1" -only-testing:MuesliTests/BlendRendererTests -parallel-testing-enabled NO +``` + +- [ ] **Step 3: Implement the minimal renderer** + +Create `src/mobile/Muesli/Views/Components/BlendRenderer.swift`: + +```swift +// +// BlendRenderer.swift +// Muesli +// +// Pure value-type renderer that converts a Note's blend pipeline output +// (blendedMarkdown + parallel char-range arrays + photos) into a list of +// display segments. The view layer iterates the segments and draws each. +// + +import Foundation + +/// A single segment of the augmented-note display. +enum BlendSegment: Equatable { + case text(AttributedString) + /// A full-width photo card with optional caption (taken from blend output). + case photo(Photo, caption: String?) +} + +enum BlendRenderer { + + /// Returns the list of display segments for a Note. Empty if the note has + /// no `blendedMarkdown`. Defensive against bad span offsets (clamped + skipped). + static func render(note: Note) -> [BlendSegment] { + guard let markdown = note.blendedMarkdown, !markdown.isEmpty else { return [] } + + // Decode citations (optional — empty fallback is fine). + let citations: BlendCitations = (note.blendCitationsJSON.flatMap { + try? JSONDecoder().decode(BlendCitations.self, from: $0) + }) ?? BlendCitations(userNoteSpans: [], quoteSpans: [], imagePlacements: [], citations: []) + + // Build the base AttributedString from the raw markdown text. We do + // NOT use SwiftUI's markdown init because the blend service's char + // offsets are into the raw source, and parsing would shift indices. + var base = AttributedString(markdown) + + // Apply overlays. + applyUserNoteSpans(citations.userNoteSpans, on: &base) + applyQuoteSpans(citations.quoteSpans, on: &base) + applyCitations(citations.citations, on: &base) + + // Split at image placements into a sequence of text segments and photo cards. + return splitAtImagePlacements( + base: base, + placements: citations.imagePlacements, + photos: note.photos + ) + } + + // MARK: - Overlays + + private static func applyUserNoteSpans(_ spans: [UserNoteSpan], on attr: inout AttributedString) { + for span in spans { + guard let range = range(in: attr, start: span.start, end: span.end) else { continue } + attr[range].inlinePresentationIntent = .stronglyEmphasized + attr[range].foregroundColor = .accentColor + } + } + + private static func applyQuoteSpans(_ spans: [QuoteSpan], on attr: inout AttributedString) { + for span in spans { + guard let range = range(in: attr, start: span.start, end: span.end) else { continue } + attr[range].inlinePresentationIntent = .emphasized + // Custom attributes documenting the audio target. The view layer + // reads these to render a quote bar and (later) wire the tap. + attr[range].quoteStartSec = span.transcriptStart + attr[range].quoteEndSec = span.transcriptEnd + } + } + + private static func applyCitations(_ cites: [Citation], on attr: inout AttributedString) { + for c in cites { + guard let range = range(in: attr, start: c.blendStart, end: c.blendEnd) else { continue } + attr[range].underlineStyle = .single + attr[range].citationTranscriptStart = c.transcriptStart + attr[range].citationTranscriptEnd = c.transcriptEnd + } + } + + // MARK: - Image placement splitting + + private static func splitAtImagePlacements( + base: AttributedString, + placements: [ImagePlacement], + photos: [Photo] + ) -> [BlendSegment] { + let count = base.characters.count + let photoById = Dictionary(uniqueKeysWithValues: photos.map { ($0.id.uuidString, $0) }) + + // Sort by offset ascending; drop offsets that point outside the string + // or reference a photo not in note.photos. + let validPlacements = placements + .filter { $0.charOffset >= 0 && $0.charOffset <= count } + .filter { photoById[$0.imageId] != nil } + .sorted { $0.charOffset < $1.charOffset } + + if validPlacements.isEmpty { + return [.text(base)] + } + + var segments: [BlendSegment] = [] + var cursor = 0 + for p in validPlacements { + if p.charOffset > cursor { + let lo = base.index(base.startIndex, offsetByCharacters: cursor) + let hi = base.index(base.startIndex, offsetByCharacters: p.charOffset) + segments.append(.text(AttributedString(base[lo.. Range? { + let count = attr.characters.count + let lo = max(0, min(start, count)) + let hi = max(lo, min(end, count)) + guard lo < hi else { return nil } + let from = attr.index(attr.startIndex, offsetByCharacters: lo) + let to = attr.index(attr.startIndex, offsetByCharacters: hi) + return from..(dynamicMember keyPath: KeyPath) -> T { + self[T.self] + } +} +``` + +- [ ] **Step 4: Run, expect PASS** + +- [ ] **Step 5: Commit** + +```bash +git add src/mobile/Muesli/Views/Components/BlendRenderer.swift \ + src/mobile/MuesliTests/Views/BlendRendererTests.swift + +git commit -m "$(cat <<'EOF' +feat(ios): BlendRenderer — pure value renderer for blended notes + +Converts a Note's blendedMarkdown + parallel char-range arrays +(userNoteSpans, quoteSpans, imagePlacements, citations) plus its +photos into a [BlendSegment] list. Each segment is either +.text(AttributedString) with attribute overlays applied or a +.photo(Photo, caption) card. + +The renderer treats blendedMarkdown as raw source text rather than +parsing it through SwiftUI's markdown init, because the blend +service's char offsets are into the raw source and any markdown +parser would shift indices. Custom AttributedString attribute keys +carry transcript timestamps for later tap-to-seek wiring (Phase 5). + +Defensive against bad span offsets (clamps + drops invalid ranges) +and photo placements pointing at photos no longer in note.photos. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Renderer overlay tests + +**Files:** +- Test: `src/mobile/MuesliTests/Views/BlendRendererTests.swift` (extend) + +- [ ] **Step 1: Add tests for userNoteSpans, quoteSpans, citations, image splitting, and edge cases** + +```swift + @Test("Renderer bolds userNoteSpans ranges") + @MainActor + func userNoteSpansApplied() async throws { + let container = try makeContainer() + let note = Note(title: "x") + note.blendedMarkdown = "AI prose then USER NOTES and more AI." + let bc = BlendCitations( + userNoteSpans: [UserNoteSpan(start: 14, end: 24)], // "USER NOTES" + quoteSpans: [], imagePlacements: [], citations: [] + ) + note.blendCitationsJSON = try JSONEncoder().encode(bc) + container.mainContext.insert(note) + + let segments = BlendRenderer.render(note: note) + #expect(segments.count == 1) + guard case .text(let attr) = segments[0] else { Issue.record("expected text"); return } + + let lo = attr.index(attr.startIndex, offsetByCharacters: 14) + let hi = attr.index(attr.startIndex, offsetByCharacters: 24) + let attrs = attr[lo.." +``` + +--- + +## Task 3: `SlideCard` component + +**Files:** +- Create: `src/mobile/Muesli/Views/Components/SlideCard.swift` + +- [ ] **Step 1: Create the component** + +```swift +// +// SlideCard.swift +// Muesli +// +// Full-width photo card used between text segments in AugmentedNoteView. +// Loads the image from Photo.localPath; shows a placeholder if missing. +// + +import SwiftUI + +struct SlideCard: View { + let photo: Photo + let caption: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let uiImage = loadImage() { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.15)) + .frame(height: 180) + .overlay( + Image(systemName: "photo") + .font(.title) + .foregroundColor(.secondary) + ) + } + + if let ocr = photo.ocrText, !ocr.isEmpty { + Text(ocr) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + + if let caption, !caption.isEmpty { + Text(caption) + .font(.footnote) + .foregroundColor(.primary) + } + } + .padding(.vertical, 8) + } + + private func loadImage() -> UIImage? { + let url: URL? = photo.localPath.hasPrefix("/") + ? URL(fileURLWithPath: photo.localPath) + : AudioRecordingManager.shared.getRecordingURL(fileName: photo.localPath) + guard let url else { return nil } + return UIImage(contentsOfFile: url.path) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/mobile/Muesli/Views/Components/SlideCard.swift +git commit -m "feat(ios): SlideCard component for AugmentedNoteView photo segments + +Loads the image from Photo.localPath; shows a placeholder card if +the file is missing. Renders OCR text as a caption and the optional +blend-provided caption as a footnote. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: `AugmentedNoteView` + +**Files:** +- Create: `src/mobile/Muesli/Views/AugmentedNoteView.swift` + +- [ ] **Step 1: Create the view** + +```swift +// +// AugmentedNoteView.swift +// Muesli +// +// Flagship note detail view: renders blendedMarkdown + parallel char-range +// overlays + photo cards as a vertically-scrolling document. +// + +import SwiftUI + +struct AugmentedNoteView: View { + let note: Note + + private var segments: [BlendSegment] { + BlendRenderer.render(note: note) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + header + + if segments.isEmpty { + blendStatusFallback + } else { + ForEach(Array(segments.enumerated()), id: \.offset) { _, seg in + switch seg { + case .text(let attr): + Text(attr) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + case .photo(let photo, let caption): + SlideCard(photo: photo, caption: caption) + } + } + } + } + .padding() + } + .navigationTitle(note.title) + .navigationBarTitleDisplayMode(.inline) + } + + // MARK: - Subviews + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + if let conf = note.resolvedConferenceName { + Text(conf) + .font(.caption.weight(.semibold)) + .foregroundColor(.accentColor) + } + if let speaker = note.speaker { + Text("· \(speaker)").font(.caption).foregroundColor(.secondary) + } + Text("· \(note.dateString)").font(.caption).foregroundColor(.secondary) + } + Text(note.title) + .font(.title2.weight(.bold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var blendStatusFallback: some View { + switch note.blendStatus { + case .idle, .transcribing, .transcribed, .extracting, .blending: + VStack(spacing: 8) { + ProgressView() + Text("Preparing note…").font(.footnote).foregroundColor(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.top, 24) + case .failed: + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundColor(.orange) + Text(note.blendError ?? "Blend failed.").font(.footnote) + } + .frame(maxWidth: .infinity) + .padding(.top, 24) + case .complete: + // blendedMarkdown nil despite .complete — unexpected. Show transcript or content. + Text(note.transcript ?? note.content).frame(maxWidth: .infinity, alignment: .leading) + } + } +} +``` + +- [ ] **Step 2: Build to verify it compiles** + +``` +xcodebuild build -scheme Muesli -destination "platform=iOS Simulator,name=iPhone 17,OS=26.1" +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/mobile/Muesli/Views/AugmentedNoteView.swift +git commit -m "feat(ios): AugmentedNoteView — flagship blended note display + +Renders the BlendRenderer segment list. Header shows conference + +speaker + date eyebrow with the note title. Empty-segment state +shows a blend-status appropriate placeholder: a progress spinner +during the transcribe / blend pipeline, a failure card with the +blend error, and the raw transcript or content as a last-resort +fallback when status is .complete but blendedMarkdown is nil. + +Tap-to-seek wiring on quoteSpans and citations comes in Phase 5 +with ChapteredPlaybackView; the AttributedString attributes are +already in place to drive it. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 3 done when + +- All four tasks committed. +- `BlendRendererTests` green: empty, single text, userNoteSpans, quoteSpans, citations, image splitting, defensive offsets — 7 tests. +- Build green; no warnings introduced. +- `AugmentedNoteView` renders a sample note in Xcode preview / simulator (manual smoke). + +## Next plan + +Phase 4 wires `MainView` + `ConferenceDetailView` (notes-list grouping and the conference hero), which in turn pushes `AugmentedNoteView` into the navigation stack. diff --git a/docs/superpowers/plans/2026-05-12-phase-4-main-and-conference.md b/docs/superpowers/plans/2026-05-12-phase-4-main-and-conference.md new file mode 100644 index 0000000..427f79e --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-phase-4-main-and-conference.md @@ -0,0 +1,550 @@ +# Phase 4: MainView + ConferenceDetailView Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans or superpowers:subagent-driven-development. + +**Goal:** Replace the existing `SimpleMainView` flat list with `MainView`, which groups notes by `Conference` (and surfaces an "Other" section for ungrouped notes). Add `ConferenceDetailView` for the conference hero. Both screens push `AugmentedNoteView` for note detail. This is the navigation shell that finally hosts the Phase 3 renderer. + +**Architecture:** Two new SwiftUI views + a shared `NoteRow` component. `MainView` reads `[Note]` and `[Conference]` from SwiftData and partitions them in a computed property. `ConferenceDetailView` takes a `Conference` and renders its notes. Both views use the existing `NotesListView`/row components where helpful, but a fresh `NoteRow` matches the design (speaker + date + slide count). The app's `WindowGroup` switches from `SimpleMainView` to `MainView`. + +**Tech Stack:** SwiftUI, SwiftData `@Query`, Swift Testing. + +**Spec reference:** `docs/superpowers/specs/2026-05-12-gap-close-design.md` § Scene i, vii. + +**Deferred:** +- The "Chat with this conference" CTA on the conference detail screen wires up in Phase 6 (`ChatView`); for now it's a disabled button with the right label so the design lands. +- Stale-recording banner, search bar, and FAB are kept as-is from existing components. + +--- + +## File Structure + +**Creating:** +- `src/mobile/Muesli/Views/MainView.swift` +- `src/mobile/Muesli/Views/ConferenceDetailView.swift` +- `src/mobile/Muesli/Views/Components/NoteRow.swift` +- `src/mobile/MuesliTests/Views/MainViewTests.swift` (logic-only; no UI snapshot) +- `src/mobile/MuesliTests/Views/ConferenceDetailViewTests.swift` + +**Modifying:** +- `src/mobile/Muesli/MuesliApp.swift` — flip `WindowGroup` root from `SimpleMainView` to `MainView` + +--- + +## Task 1: `NoteRow` component + +**Files:** +- Create: `src/mobile/Muesli/Views/Components/NoteRow.swift` + +- [ ] **Step 1: Write the component** + +```swift +// +// NoteRow.swift +// Muesli +// +// Notes-list row: title + (speaker · relative date · slide count · photo count). +// + +import SwiftUI + +struct NoteRow: View { + let note: Note + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(note.title) + .font(.body.weight(.semibold)) + .lineLimit(2) + HStack(spacing: 4) { + if let conf = note.resolvedConferenceName { + Text(conf).font(.caption.weight(.semibold)).foregroundColor(.accentColor) + dot + } + if let speaker = note.speaker { + Text(speaker).font(.caption).foregroundColor(.secondary) + dot + } + Text(relativeDate(note.timestamp)).font(.caption).foregroundColor(.secondary) + if !note.photos.isEmpty { + dot + Text("\(note.photos.count) slides").font(.caption).foregroundColor(.secondary) + } + } + } + .padding(.vertical, 4) + } + + private var dot: some View { + Text("·").font(.caption).foregroundColor(.secondary) + } + + private func relativeDate(_ date: Date) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return f.localizedString(for: date, relativeTo: Date()) + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/mobile/Muesli/Views/Components/NoteRow.swift +git commit -m "feat(ios): NoteRow component — title + speaker + date + slide count + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: `MainView` — conference-grouped notes list + +**Files:** +- Create: `src/mobile/Muesli/Views/MainView.swift` +- Test: `src/mobile/MuesliTests/Views/MainViewTests.swift` + +- [ ] **Step 1: Write failing tests for the grouping logic** + +```swift +// +// MainViewTests.swift +// MuesliTests +// +// Logic tests for MainView's conference-grouping helper. We don't render +// the view; we exercise the static partition function. +// + +import Testing +import Foundation +import SwiftData +@testable import Muesli + +@Suite("Main View Tests", .tags(.unit)) +struct MainViewTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test("partition groups notes by conference relationship and bucks ungrouped notes into Other") + @MainActor + func partitionGroupsByConference() async throws { + let container = try makeContainer() + let context = container.mainContext + + let summit = Conference(name: "DataSummit 2026") + let solo = Note(title: "Standup") + let talk1 = Note(title: "Three pillars", conference: summit) + let talk2 = Note(title: "Streaming", conference: summit) + [summit, solo, talk1, talk2].forEach { context.insert($0) } + try context.save() + + let groups = MainView.partition(notes: [solo, talk1, talk2]) + + // Conference group + ungrouped + #expect(groups.count == 2) + let summitGroup = groups.first { $0.conference?.id == summit.id } + #expect(summitGroup?.notes.count == 2) + let other = groups.first { $0.conference == nil } + #expect(other?.notes.count == 1) + #expect(other?.notes.first?.title == "Standup") + } + + @Test("partition orders conference groups by most-recent note descending") + @MainActor + func conferenceGroupsOrderedByRecency() async throws { + let container = try makeContainer() + let context = container.mainContext + + let older = Conference(name: "Older 2024") + let newer = Conference(name: "Newer 2026") + let n1 = Note(title: "Old", timestamp: Date(timeIntervalSinceNow: -1_000_000), conference: older) + let n2 = Note(title: "Recent", timestamp: Date(timeIntervalSinceNow: -1_000), conference: newer) + [older, newer, n1, n2].forEach { context.insert($0) } + try context.save() + + let groups = MainView.partition(notes: [n1, n2]) + #expect(groups.first?.conference?.id == newer.id) + } +} +``` + +- [ ] **Step 2: Run, expect FAIL** + +``` +xcodebuild test ... -only-testing:MuesliTests/MainViewTests +``` + +- [ ] **Step 3: Implement `MainView`** + +```swift +// +// MainView.swift +// Muesli +// +// Conference-grouped notes list. Sections by Conference (most recently +// active first), with an Other section for ungrouped notes. Each row +// pushes AugmentedNoteView. +// + +import SwiftUI +import SwiftData + +struct MainView: View { + @Environment(\.modelContext) private var modelContext + @Query(filter: #Predicate { !$0.isArchived }, sort: \Note.timestamp, order: .reverse) + private var notes: [Note] + + @State private var showingNewNote = false + + /// Group rendered in the list — either tied to a Conference or the Other bucket. + struct Group: Identifiable { + let conference: Conference? + let notes: [Note] + var id: String { conference?.id.uuidString ?? "other" } + } + + static func partition(notes: [Note]) -> [Group] { + var byConferenceId: [UUID: (Conference, [Note])] = [:] + var ungrouped: [Note] = [] + for note in notes { + if let conf = note.conference { + if byConferenceId[conf.id] == nil { + byConferenceId[conf.id] = (conf, []) + } + byConferenceId[conf.id]?.1.append(note) + } else { + ungrouped.append(note) + } + } + var groups = byConferenceId.values.map { Group(conference: $0.0, notes: $0.1) } + // Sort conference groups by their most-recent note descending. + groups.sort { (a, b) in + let aDate = a.notes.map(\.timestamp).max() ?? .distantPast + let bDate = b.notes.map(\.timestamp).max() ?? .distantPast + return aDate > bDate + } + if !ungrouped.isEmpty { + groups.append(Group(conference: nil, notes: ungrouped)) + } + return groups + } + + private var groups: [Group] { Self.partition(notes: notes) } + + var body: some View { + NavigationStack { + content + .navigationTitle("Notes") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingNewNote = true + } label: { + Label("New note", systemImage: "plus.circle.fill") + } + } + } + .sheet(isPresented: $showingNewNote) { + NewNoteView() + } + } + } + + @ViewBuilder + private var content: some View { + if notes.isEmpty { + ContentUnavailableView( + "No notes yet", + systemImage: "doc.text", + description: Text("Tap + to record your first note.") + ) + } else { + List { + ForEach(groups) { group in + Section { + ForEach(group.notes) { note in + NavigationLink(value: note) { + NoteRow(note: note) + } + } + } header: { + if let conference = group.conference { + NavigationLink(value: conference) { + HStack { + Text(conference.name) + .font(.headline) + Spacer() + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + } + .buttonStyle(.plain) + } else { + Text("Other") + .font(.headline) + } + } + } + } + .navigationDestination(for: Note.self) { note in + AugmentedNoteView(note: note) + } + .navigationDestination(for: Conference.self) { conference in + ConferenceDetailView(conference: conference) + } + } + } +} +``` + +- [ ] **Step 4: Run tests + build** + +``` +xcodebuild test ... -only-testing:MuesliTests/MainViewTests +xcodebuild build ... +``` + +(Build will fail until Task 3 lands `ConferenceDetailView`. That's expected — commit Task 2 + Task 3 together.) + +--- + +## Task 3: `ConferenceDetailView` + +**Files:** +- Create: `src/mobile/Muesli/Views/ConferenceDetailView.swift` +- Test: `src/mobile/MuesliTests/Views/ConferenceDetailViewTests.swift` + +- [ ] **Step 1: Tests for the date-range / talk-count helpers** + +```swift +// +// ConferenceDetailViewTests.swift +// MuesliTests +// + +import Testing +import Foundation +import SwiftData +@testable import Muesli + +@Suite("Conference Detail View Tests", .tags(.unit)) +struct ConferenceDetailViewTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test("dateRangeString uses explicit conference dates when present") + @MainActor + func dateRangeFromExplicitDates() async throws { + let conf = Conference( + name: "X", + startDate: Date(timeIntervalSince1970: 1_750_000_000), + endDate: Date(timeIntervalSince1970: 1_750_500_000) + ) + let s = ConferenceDetailView.dateRangeString(conference: conf) + #expect(s != nil) + #expect(!s!.isEmpty) + } + + @Test("dateRangeString returns nil when both dates are nil and no notes attached") + @MainActor + func dateRangeNilWhenAbsent() async throws { + let conf = Conference(name: "X") + #expect(ConferenceDetailView.dateRangeString(conference: conf) == nil) + } +} +``` + +- [ ] **Step 2: Implement `ConferenceDetailView`** + +```swift +// +// ConferenceDetailView.swift +// Muesli +// +// Hero header for one conference: name, location, date range, description, +// and a chronological list of talks under it. Chat CTA is a placeholder +// until Phase 6 lands ChatView. +// + +import SwiftUI + +struct ConferenceDetailView: View { + let conference: Conference + + private var notes: [Note] { + conference.notes.sorted { $0.timestamp > $1.timestamp } + } + + var body: some View { + List { + Section { + hero + .listRowInsets(EdgeInsets()) + .padding() + } + + Section("Talks · \(notes.count)") { + if notes.isEmpty { + Text("No talks yet.").foregroundColor(.secondary) + } else { + ForEach(notes) { note in + NavigationLink(value: note) { + NoteRow(note: note) + } + } + } + } + } + .navigationTitle(conference.name) + .navigationBarTitleDisplayMode(.inline) + } + + private var hero: some View { + VStack(alignment: .leading, spacing: 8) { + Text(conference.name) + .font(.largeTitle.weight(.bold)) + if let loc = conference.location { + Label(loc, systemImage: "mappin.and.ellipse") + .font(.subheadline) + .foregroundColor(.secondary) + } + if let range = Self.dateRangeString(conference: conference) { + Label(range, systemImage: "calendar") + .font(.subheadline) + .foregroundColor(.secondary) + } + if let desc = conference.conferenceDescription, !desc.isEmpty { + Text(desc) + .font(.body) + .padding(.top, 4) + } + + Button { + // Phase 6 wires this to ChatView scoped to this conference. + } label: { + Label("Chat with this conference", systemImage: "bubble.left.and.bubble.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 8) + .disabled(true) + } + } + + /// Builds a "Mar 14 – Mar 16, 2026" style string from explicit conference + /// dates if present, falling back to min/max of attached note timestamps. + /// Returns nil when no date information is available. + static func dateRangeString(conference: Conference) -> String? { + let start = conference.startDate ?? conference.notes.map(\.timestamp).min() + let end = conference.endDate ?? conference.notes.map(\.timestamp).max() + guard let start, let end else { return nil } + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, yyyy" + if Calendar.current.isDate(start, inSameDayAs: end) { + return formatter.string(from: start) + } + formatter.dateFormat = "MMM d" + let s = formatter.string(from: start) + let e: String + if Calendar.current.component(.year, from: start) == Calendar.current.component(.year, from: end) { + e = DateFormatter.localizedString(from: end, dateStyle: .medium, timeStyle: .none) + } else { + e = DateFormatter.localizedString(from: end, dateStyle: .medium, timeStyle: .none) + } + return "\(s) – \(e)" + } +} +``` + +- [ ] **Step 3: Build + run tests** + +``` +xcodebuild build ... +xcodebuild test ... -only-testing:MuesliTests/ConferenceDetailViewTests +``` + +- [ ] **Step 4: Commit Tasks 2 + 3 together** + +```bash +git add src/mobile/Muesli/Views/MainView.swift \ + src/mobile/Muesli/Views/ConferenceDetailView.swift \ + src/mobile/MuesliTests/Views/MainViewTests.swift \ + src/mobile/MuesliTests/Views/ConferenceDetailViewTests.swift + +git commit -m "feat(ios): MainView + ConferenceDetailView + +MainView groups notes by Conference (most recently active first) +with an Other bucket for ungrouped notes. Each row pushes +AugmentedNoteView via NavigationStack value-based destinations. +Tapping a conference section header pushes ConferenceDetailView. + +ConferenceDetailView shows the conference name, location, date +range, and description as a hero, then lists the conference's +talks in chronological order. dateRangeString falls back to +min/max of attached note timestamps when explicit conference dates +are missing. The Chat with this conference button is a disabled +placeholder until Phase 6. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: Switch app root to `MainView` + +**Files:** +- Modify: `src/mobile/Muesli/MuesliApp.swift` + +- [ ] **Step 1: Replace WindowGroup body** + +In `MuesliApp.swift`: + +```swift + var body: some Scene { + WindowGroup { + MainView() + } + .modelContainer(sharedModelContainer) + } +``` + +- [ ] **Step 2: Build + smoke run** + +``` +xcodebuild build ... +``` + +(Then open the simulator manually if convenient and confirm the new MainView appears.) + +- [ ] **Step 3: Commit** + +```bash +git add src/mobile/Muesli/MuesliApp.swift +git commit -m "feat(ios): flip app root from SimpleMainView to MainView + +SimpleMainView is now orphaned; Phase 9 deletes it after the +salvage harvest completes. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 4 done when + +- Four tasks committed. +- `MainViewTests` and `ConferenceDetailViewTests` green. +- Build green; lint clean. +- Simulator smoke: launch shows conference sections grouping the sample data, tapping a row pushes the augmented note, tapping a conference header pushes the conference detail with the "Chat with this conference" disabled button visible. + +## Next plan + +Phase 5: `ChapteredPlaybackView` — wires the tap-to-seek attributes baked into Phase 3. diff --git a/docs/superpowers/plans/2026-05-12-phase-5-chaptered-playback.md b/docs/superpowers/plans/2026-05-12-phase-5-chaptered-playback.md new file mode 100644 index 0000000..a3009b4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-phase-5-chaptered-playback.md @@ -0,0 +1,663 @@ +# Phase 5: ChapteredPlaybackView Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans or superpowers:subagent-driven-development. + +**Goal:** Full-screen sheet that plays a note's audio with a custom scrubber, chapter markers from `note.chaptersJSON`, play/pause + skip-chapter buttons, and a tappable chapter list below. + +**Architecture:** +- `ChapterModel` — value type decoded from `note.chaptersJSON`. +- `PlaybackTimer` — pure helper that, given `currentTime` and the chapter list, returns the current chapter index. +- `ChapteredPlaybackController` — `@Observable` class wrapping `AVAudioPlayer` so the view binds against published state (`currentTime`, `isPlaying`, `duration`). Exposes `play()`, `pause()`, `seek(to:)`, `skipChapter(offset:)`. Pure-logic helpers are unit tested; the AVAudioPlayer surface is exercised manually in the simulator. +- `ChapterScrubber` — SwiftUI component drawing the track + chapter ticks + thumb. Drag updates a binding; the host view commits to the controller's `seek(to:)`. +- `ChapteredPlaybackView` — assembles header + scrubber + controls + chapter list. +- `AugmentedNoteView` gains a "Listen" CTA that presents this view at chapter 0. Per-run tap-to-seek on quoteSpans/citations is deferred to a future enhancement (SwiftUI `Text` doesn't expose per-run gestures; would need a `UIViewRepresentable` wrapping `UITextView`). + +**Spec reference:** `docs/superpowers/specs/2026-05-12-gap-close-design.md` § Scene ix. + +**Deferred:** +- Per-run tap-to-seek inside AugmentedNoteView text. The blend-pipeline char ranges and AttributedString attribute keys are already in place; only the gesture-capture layer is missing. +- Background-audio Live Activity (Phase 8). + +--- + +## File Structure + +**Creating:** +- `src/mobile/Muesli/Views/Components/ChapterScrubber.swift` +- `src/mobile/Muesli/Views/Components/PlaybackTimer.swift` — pure helpers +- `src/mobile/Muesli/Views/ChapteredPlaybackController.swift` — `@Observable` +- `src/mobile/Muesli/Views/ChapteredPlaybackView.swift` +- `src/mobile/MuesliTests/Views/PlaybackTimerTests.swift` + +**Modifying:** +- `src/mobile/Muesli/Views/AugmentedNoteView.swift` — add "Listen" button presenting `ChapteredPlaybackView` + +--- + +## Task 1: `PlaybackTimer` pure helper + tests + +**Files:** +- Create: `src/mobile/Muesli/Views/Components/PlaybackTimer.swift` +- Test: `src/mobile/MuesliTests/Views/PlaybackTimerTests.swift` + +- [ ] **Step 1: Failing tests** + +```swift +import Testing +import Foundation +@testable import Muesli + +@Suite("Playback Timer Tests", .tags(.unit)) +struct PlaybackTimerTests { + + private func chapters() -> [ChapterModel] { + [ + ChapterModel(start: 0, title: "Intro", summary: ""), + ChapterModel(start: 120, title: "Middle", summary: ""), + ChapterModel(start: 480, title: "Outro", summary: "") + ] + } + + @Test("currentChapterIndex returns 0 before second chapter starts") + func beforeSecond() { + #expect(PlaybackTimer.currentChapterIndex(at: 0, chapters: chapters()) == 0) + #expect(PlaybackTimer.currentChapterIndex(at: 60, chapters: chapters()) == 0) + #expect(PlaybackTimer.currentChapterIndex(at: 119.9, chapters: chapters()) == 0) + } + + @Test("currentChapterIndex returns the chapter whose start <= time") + func picksLastSatisfying() { + #expect(PlaybackTimer.currentChapterIndex(at: 120, chapters: chapters()) == 1) + #expect(PlaybackTimer.currentChapterIndex(at: 200, chapters: chapters()) == 1) + #expect(PlaybackTimer.currentChapterIndex(at: 480, chapters: chapters()) == 2) + #expect(PlaybackTimer.currentChapterIndex(at: 999, chapters: chapters()) == 2) + } + + @Test("currentChapterIndex returns 0 for empty chapter list") + func emptyChapters() { + #expect(PlaybackTimer.currentChapterIndex(at: 42, chapters: []) == 0) + } + + @Test("format mm:ss renders seconds with leading zeros") + func formatBasic() { + #expect(PlaybackTimer.formatTime(0) == "00:00") + #expect(PlaybackTimer.formatTime(9) == "00:09") + #expect(PlaybackTimer.formatTime(65) == "01:05") + #expect(PlaybackTimer.formatTime(3599) == "59:59") + } + + @Test("format hh:mm:ss for >= 1 hour") + func formatHours() { + #expect(PlaybackTimer.formatTime(3600) == "1:00:00") + #expect(PlaybackTimer.formatTime(3725) == "1:02:05") + } + + @Test("Decoding chapters from JSON returns model values") + func decodeChapters() throws { + let json = """ + {"chapters":[ + {"start":0.0,"title":"Opening","summary":"intro"}, + {"start":120.5,"title":"Middle","summary":""} + ]} + """ + let data = Data(json.utf8) + let chapters = PlaybackTimer.decodeChapters(from: data) + #expect(chapters.count == 2) + #expect(chapters.first?.title == "Opening") + #expect(chapters[1].start == 120.5) + } + + @Test("Decoding chapters from nil or malformed data returns empty list") + func decodeBad() { + #expect(PlaybackTimer.decodeChapters(from: nil).isEmpty) + #expect(PlaybackTimer.decodeChapters(from: Data("not json".utf8)).isEmpty) + } +} +``` + +- [ ] **Step 2: Implement** + +```swift +// +// PlaybackTimer.swift +// Muesli +// +// Pure helpers used by the chaptered playback view: decode chapters, +// pick the current chapter for a playback time, and format times. +// + +import Foundation + +struct ChapterModel: Equatable, Identifiable { + var id: Int // index in the list + var start: Double + var title: String + var summary: String + + init(id: Int = 0, start: Double, title: String, summary: String) { + self.id = id + self.start = start + self.title = title + self.summary = summary + } +} + +enum PlaybackTimer { + + /// Decode chapters from the JSON shape SessionsService.runBlend persists + /// to `note.chaptersJSON`. Returns an empty list on missing / malformed + /// input. + static func decodeChapters(from data: Data?) -> [ChapterModel] { + guard let data else { return [] } + guard let wrapper = try? JSONDecoder().decode(ChaptersWrapper.self, from: data) else { return [] } + return wrapper.chapters.enumerated().map { idx, dto in + ChapterModel(id: idx, start: dto.start, title: dto.title, summary: dto.summary ?? "") + } + } + + /// Returns the index of the chapter whose `start <= time`, picking the + /// last satisfying. Returns 0 when chapters is empty or `time` is before + /// the first chapter. + static func currentChapterIndex(at time: Double, chapters: [ChapterModel]) -> Int { + guard !chapters.isEmpty else { return 0 } + var index = 0 + for (i, chapter) in chapters.enumerated() where chapter.start <= time { + index = i + } + return index + } + + /// mm:ss for times < 1h, h:mm:ss otherwise. + static func formatTime(_ seconds: Double) -> String { + let total = max(0, Int(seconds.rounded(.toNearestOrEven))) + let h = total / 3600 + let m = (total % 3600) / 60 + let s = total % 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m, s) + } + return String(format: "%02d:%02d", m, s) + } +} +``` + +- [ ] **Step 3: Run, expect PASS** + +``` +xcodebuild test ... -only-testing:MuesliTests/PlaybackTimerTests +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/mobile/Muesli/Views/Components/PlaybackTimer.swift \ + src/mobile/MuesliTests/Views/PlaybackTimerTests.swift +git commit -m "feat(ios): PlaybackTimer — chapter decode + index picker + formatter + +Pure helpers underpinning the chaptered playback view. Decodes +note.chaptersJSON into ChapterModel values, returns the chapter +index for a given playback time (defaults to 0 before the first +chapter or for empty lists), and formats playback time as mm:ss +or h:mm:ss past one hour. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: `ChapteredPlaybackController` (`AVAudioPlayer` wrapper) + +**Files:** +- Create: `src/mobile/Muesli/Views/ChapteredPlaybackController.swift` + +This is a thin observable wrapper. Not unit-tested directly (real audio sessions); the surface is small enough to verify in the simulator. + +- [ ] **Step 1: Write the controller** + +```swift +// +// ChapteredPlaybackController.swift +// Muesli +// +// @Observable wrapper around AVAudioPlayer that publishes currentTime / +// isPlaying / duration so the view binds against player state. Pure-logic +// helpers live in PlaybackTimer. +// + +import Foundation +import AVFoundation + +@MainActor +@Observable +final class ChapteredPlaybackController { + private(set) var currentTime: Double = 0 + private(set) var duration: Double = 0 + private(set) var isPlaying: Bool = false + private(set) var loadError: String? + + private var player: AVAudioPlayer? + private var timer: Timer? + + func load(audioFileURL url: URL) { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) + try AVAudioSession.sharedInstance().setActive(true) + let p = try AVAudioPlayer(contentsOf: url) + p.prepareToPlay() + self.player = p + self.duration = p.duration + self.currentTime = 0 + self.loadError = nil + } catch { + self.loadError = error.localizedDescription + AppLogger.shared.error("ChapteredPlaybackController: failed to load \(url.lastPathComponent)", error: error) + } + } + + func play() { + guard let player else { return } + player.play() + isPlaying = true + startTimer() + } + + func pause() { + player?.pause() + isPlaying = false + stopTimer() + } + + func toggle() { + isPlaying ? pause() : play() + } + + func seek(to seconds: Double) { + guard let player else { return } + let clamped = max(0, min(seconds, duration)) + player.currentTime = clamped + currentTime = clamped + } + + func skipChapter(offset: Int, chapters: [ChapterModel]) { + let current = PlaybackTimer.currentChapterIndex(at: currentTime, chapters: chapters) + let target = max(0, min(current + offset, chapters.count - 1)) + guard chapters.indices.contains(target) else { return } + seek(to: chapters[target].start) + } + + deinit { + timer?.invalidate() + } + + private func startTimer() { + stopTimer() + timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in + Task { @MainActor in + guard let self, let player = self.player else { return } + self.currentTime = player.currentTime + if !player.isPlaying && self.isPlaying { + self.isPlaying = false + self.stopTimer() + } + } + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/mobile/Muesli/Views/ChapteredPlaybackController.swift +git commit -m "feat(ios): ChapteredPlaybackController — @Observable AVAudioPlayer wrapper + +Loads from a file URL, publishes currentTime/duration/isPlaying, +exposes play/pause/seek/skipChapter. A 0.25s timer polls +AVAudioPlayer's currentTime to drive the scrubber. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: `ChapterScrubber` component + +**Files:** +- Create: `src/mobile/Muesli/Views/Components/ChapterScrubber.swift` + +- [ ] **Step 1: Write the component** + +```swift +// +// ChapterScrubber.swift +// Muesli +// +// Horizontal track with chapter-boundary ticks and a draggable thumb. +// Reports drag through a binding; the host commits via `seek(to:)`. +// + +import SwiftUI + +struct ChapterScrubber: View { + let duration: Double + let chapters: [ChapterModel] + @Binding var currentTime: Double + /// Whether the user is currently dragging. The host should suspend + /// timer-driven updates while this is true to avoid the thumb jumping. + @Binding var isDragging: Bool + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + // Track + Capsule() + .fill(Color.gray.opacity(0.2)) + .frame(height: 6) + + // Played portion + Capsule() + .fill(Color.accentColor) + .frame(width: progressWidth(in: geo.size.width), height: 6) + + // Chapter ticks + ForEach(chapters) { chapter in + let x = positionFor(time: chapter.start, in: geo.size.width) + Rectangle() + .fill(Color.primary.opacity(0.4)) + .frame(width: 2, height: 12) + .offset(x: x - 1) + } + + // Thumb + Circle() + .fill(Color.accentColor) + .frame(width: 18, height: 18) + .shadow(radius: 2) + .offset(x: progressWidth(in: geo.size.width) - 9) + } + .frame(height: 18) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + isDragging = true + let pct = max(0, min(value.location.x / geo.size.width, 1)) + currentTime = pct * max(1, duration) + } + .onEnded { _ in + isDragging = false + } + ) + } + .frame(height: 18) + } + + private func progressWidth(in total: CGFloat) -> CGFloat { + guard duration > 0 else { return 0 } + let pct = currentTime / duration + return CGFloat(max(0, min(pct, 1))) * total + } + + private func positionFor(time: Double, in total: CGFloat) -> CGFloat { + guard duration > 0 else { return 0 } + let pct = time / duration + return CGFloat(max(0, min(pct, 1))) * total + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/mobile/Muesli/Views/Components/ChapterScrubber.swift +git commit -m "feat(ios): ChapterScrubber — track + chapter ticks + draggable thumb + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 4: `ChapteredPlaybackView` + AugmentedNoteView "Listen" CTA + +**Files:** +- Create: `src/mobile/Muesli/Views/ChapteredPlaybackView.swift` +- Modify: `src/mobile/Muesli/Views/AugmentedNoteView.swift` + +- [ ] **Step 1: Write the view** + +```swift +// +// ChapteredPlaybackView.swift +// Muesli +// +// Full-screen sheet: now-playing header + chapter scrubber + transport +// controls + tappable chapter list. Audio comes from note.audioFilePath +// via AudioRecordingManager. +// + +import SwiftUI + +struct ChapteredPlaybackView: View { + let note: Note + /// Initial seek target in seconds. Used when launched from a tap on a + /// quote/citation; defaults to zero (start of audio). + var startAt: Double = 0 + + @State private var controller = ChapteredPlaybackController() + @State private var chapters: [ChapterModel] = [] + @State private var isDragging = false + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + header + .padding(.horizontal) + .padding(.top, 12) + + if let err = controller.loadError { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundColor(.orange) + Text(err).font(.footnote).multilineTextAlignment(.center) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + scrubberRow + .padding(.horizontal) + .padding(.top, 8) + transport + .padding(.top, 16) + + chapterList + } + + Spacer(minLength: 0) + } + .navigationTitle("Now playing") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .onAppear { setup() } + .onDisappear { controller.pause() } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(currentChapterDisplayIndex)") + .font(.caption.weight(.semibold)) + .foregroundColor(.accentColor) + Text(note.title) + .font(.title3.weight(.semibold)) + if let speaker = note.speaker, !speaker.isEmpty { + Text(speaker).font(.subheadline).foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var scrubberRow: some View { + VStack(spacing: 6) { + ChapterScrubber( + duration: controller.duration, + chapters: chapters, + currentTime: Binding( + get: { controller.currentTime }, + set: { newTime in + if isDragging { + controller.seek(to: newTime) + } + } + ), + isDragging: $isDragging + ) + HStack { + Text(PlaybackTimer.formatTime(controller.currentTime)) + Spacer() + Text(PlaybackTimer.formatTime(controller.duration)) + } + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + } + + private var transport: some View { + HStack(spacing: 32) { + Button { + controller.skipChapter(offset: -1, chapters: chapters) + } label: { + Image(systemName: "backward.end.fill").font(.title2) + } + .disabled(chapters.isEmpty) + + Button { + controller.toggle() + } label: { + Image(systemName: controller.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 56)) + } + + Button { + controller.skipChapter(offset: 1, chapters: chapters) + } label: { + Image(systemName: "forward.end.fill").font(.title2) + } + .disabled(chapters.isEmpty) + } + } + + private var chapterList: some View { + List { + ForEach(chapters) { chapter in + Button { + controller.seek(to: chapter.start) + controller.play() + } label: { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(chapter.title) + .font(.body.weight(.semibold)) + .foregroundColor(.primary) + if !chapter.summary.isEmpty { + Text(chapter.summary) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + Text(PlaybackTimer.formatTime(chapter.start)) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + } + } + } + .listStyle(.plain) + .padding(.top, 16) + } + + private var currentChapterDisplayIndex: String { + guard !chapters.isEmpty else { return "—" } + let i = PlaybackTimer.currentChapterIndex(at: controller.currentTime, chapters: chapters) + return String(format: "%02d", i + 1) + } + + private func setup() { + chapters = PlaybackTimer.decodeChapters(from: note.chaptersJSON) + guard let path = note.audioFilePath, + let url = AudioRecordingManager.shared.getRecordingURL(fileName: path) else { + controller.loadError = "Audio file not found." + return + } + controller.load(audioFileURL: url) + if startAt > 0 { controller.seek(to: startAt) } + } +} +``` + +- [ ] **Step 2: Add a "Listen" button to AugmentedNoteView** + +In `AugmentedNoteView.swift`, add `@State private var showingPlayback = false` and a toolbar button: + +```swift +.toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingPlayback = true + } label: { + Label("Listen", systemImage: "play.circle") + } + .disabled(note.audioFilePath == nil) + } +} +.sheet(isPresented: $showingPlayback) { + ChapteredPlaybackView(note: note) +} +``` + +- [ ] **Step 3: Build + smoke (manual simulator check optional)** + +- [ ] **Step 4: Commit** + +```bash +git add src/mobile/Muesli/Views/ChapteredPlaybackView.swift \ + src/mobile/Muesli/Views/AugmentedNoteView.swift +git commit -m "feat(ios): ChapteredPlaybackView + AugmentedNoteView Listen CTA + +Full-screen sheet plays note audio with a chapter-aware scrubber, +play/pause + skip-chapter buttons, and a tappable chapter list +underneath. Each chapter row jumps the playhead to its start and +resumes playback. + +AugmentedNoteView gains a Listen button in the toolbar that +presents this view (disabled when no audio file is attached). +Per-run tap-to-seek inside the note body remains deferred; the +attribute keys carrying transcript timestamps stay in place for +when that lands. + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Phase 5 done when + +- Four tasks committed. +- `PlaybackTimerTests` green (7 cases). +- Build green. +- Simulator smoke: a note with sample audio shows the Listen button, opens the player, scrubber jumps to chapter boundaries on the skip buttons. + +## Next plan + +Phase 6 wires `ChatView` (iOS side) against the chat backend already shipped in Phase 2. diff --git a/docs/superpowers/plans/2026-05-12-phase-6-chat-view.md b/docs/superpowers/plans/2026-05-12-phase-6-chat-view.md new file mode 100644 index 0000000..113bcf8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-phase-6-chat-view.md @@ -0,0 +1,589 @@ +# Phase 6: ChatView (iOS) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans or superpowers:subagent-driven-development. + +**Goal:** iOS `ChatView` modal sheet that talks to the chat backend shipped in Phase 2. Scope chip switches between talk (single note) and conference (multiple notes). Threads persisted to SwiftData (`ChatThread` + `ChatMessage`). Citation chips render mm:ss for transcript citations and the note title for note citations. Both `ConferenceDetailView` and `AugmentedNoteView` gain a chat entry point. + +**Architecture:** +- `LiveChatAdapter` — `ChatPort` conforming live adapter built on `URLSession`. Replaces the `UnimplementedChatAdapter` in `World.live`. +- `ChatScope` (already in `ChatPort.swift`) carries the `talk(noteId)` / `conference(conferenceId)` discriminator. +- `ChatViewModel` — `@Observable` class that owns the persisted `ChatThread`, the message list, and the in-flight send state. Resolves scope IDs to backend session IDs by reading `note.audioFilePath`-equivalent identifiers (v1 maps `Note.id ⇄ sessionId` 1:1 via the `BlendOrchestrator` flow's session creation; we already round-trip the same UUID). +- `ChatView` — assembled bubbles + scope chip + citation chips + input. + +**Spec reference:** `docs/superpowers/specs/2026-05-12-gap-close-design.md` § Scene viii. + +**Deviations:** +- The v1 backend takes `sessionIds` in the conference route. The iOS side maps `Note.id` → backend session ID; in this codebase the local Note's ID **is** the backend session ID (`BlendOrchestrator` calls `svc.createSession()` and stores its UUID on `Note`-related state). We pass `note.id.uuidString` straight through as sessionId. If that 1:1 ever breaks, ChatViewModel needs a real ID map — flagged. +- Citation tap → seek in the playback scrubber is implemented for transcript citations (presents `ChapteredPlaybackView(note:startAt:)`). Note citations push that note's `AugmentedNoteView`. + +--- + +## File Structure + +**Creating:** +- `src/mobile/Muesli/Adapters/LiveChatAdapter.swift` — production adapter +- `src/mobile/Muesli/ViewModels/ChatViewModel.swift` — `@Observable` thread state +- `src/mobile/Muesli/Views/ChatView.swift` +- `src/mobile/Muesli/Views/Components/CitationChip.swift` +- `src/mobile/MuesliTests/ViewModels/ChatViewModelTests.swift` +- `src/mobile/MuesliTests/Adapters/LiveChatAdapterTests.swift` + +**Modifying:** +- `src/mobile/Muesli/World.swift` — replace `UnimplementedChatAdapter` with `LiveChatAdapter` in `.live` +- `src/mobile/Muesli/Views/ConferenceDetailView.swift` — wire the chat button +- `src/mobile/Muesli/Views/AugmentedNoteView.swift` — add a chat button + +--- + +## Task 1: `LiveChatAdapter` + +**Files:** +- Create: `src/mobile/Muesli/Adapters/LiveChatAdapter.swift` +- Test: `src/mobile/MuesliTests/Adapters/LiveChatAdapterTests.swift` + +- [ ] **Step 1: Failing test — request encoding** + +```swift +// +// LiveChatAdapterTests.swift +// + +import Testing +import Foundation +@testable import Muesli + +@Suite("Live Chat Adapter Tests", .tags(.unit)) +struct LiveChatAdapterTests { + + /// URLProtocol stub that captures the request and returns a canned body. + final class StubProtocol: URLProtocol { + nonisolated(unsafe) static var lastRequest: URLRequest? + nonisolated(unsafe) static var responseBody: Data? + nonisolated(unsafe) static var status: Int = 200 + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + StubProtocol.lastRequest = request + // Read the request body via httpBodyStream when present. + if let stream = request.httpBodyStream { + stream.open() + var data = Data() + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { + buffer.deallocate() + stream.close() + } + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + var captured = StubProtocol.lastRequest + captured?.httpBody = data + StubProtocol.lastRequest = captured + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: StubProtocol.status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let body = StubProtocol.responseBody { + client?.urlProtocol(self, didLoad: body) + } + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} + } + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + @Test("sends talk-scope POST to /v1/sessions/:id/chat with messages body") + func talkScopeRequest() async throws { + let noteId = UUID() + StubProtocol.responseBody = """ + {"message":{"role":"assistant","content":"Hi"},"citations":[],"usage":{"tokensIn":1,"tokensOut":1}} + """.data(using: .utf8) + let adapter = LiveChatAdapter(baseURL: URL(string: "https://api.example.com")!, session: makeSession()) + let resp = try await adapter.send( + scope: .talk(noteId), + messages: [ChatTurn(role: "user", content: "hi")] + ) + #expect(resp.message.content == "Hi") + let req = try #require(StubProtocol.lastRequest) + #expect(req.httpMethod == "POST") + #expect(req.url?.path == "/v1/sessions/\(noteId.uuidString)/chat") + let body = try #require(req.httpBody) + let decoded = try JSONDecoder().decode([String: [ChatTurn]].self, from: body) + #expect(decoded["messages"]?.first?.content == "hi") + } + + @Test("sends conference-scope POST to /v1/chat with sessionIds + messages body") + func conferenceScopeRequest() async throws { + let confId = UUID() + let sessions = [UUID(), UUID()] + StubProtocol.responseBody = """ + {"message":{"role":"assistant","content":"Hi"},"citations":[],"usage":{"tokensIn":0,"tokensOut":0}} + """.data(using: .utf8) + let adapter = LiveChatAdapter(baseURL: URL(string: "https://api.example.com")!, session: makeSession()) + _ = try await adapter.send( + scope: .conference(confId), + messages: [ChatTurn(role: "user", content: "hi")], + sessionIdsResolver: { _ in sessions } + ) + let req = try #require(StubProtocol.lastRequest) + #expect(req.url?.path == "/v1/chat") + let body = try #require(req.httpBody) + struct Body: Decodable { let sessionIds: [UUID]; let messages: [ChatTurn] } + let decoded = try JSONDecoder().decode(Body.self, from: body) + #expect(decoded.sessionIds == sessions) + } + + @Test("decodes citations correctly") + func decodesCitations() async throws { + let talkId = UUID() + StubProtocol.responseBody = """ + {"message":{"role":"assistant","content":"see"}, + "citations":[ + {"kind":"transcript","talkId":"\(talkId.uuidString)","startSec":12.4,"endSec":24.1,"label":"00:12"}, + {"kind":"note","noteId":"\(talkId.uuidString)","title":"T"} + ], + "usage":{"tokensIn":0,"tokensOut":0}} + """.data(using: .utf8) + let adapter = LiveChatAdapter(baseURL: URL(string: "https://api.example.com")!, session: makeSession()) + let resp = try await adapter.send(scope: .talk(talkId), messages: [ChatTurn(role: "user", content: "?")]) + #expect(resp.citations.count == 2) + #expect(resp.citations[0].kind == .transcript) + #expect(resp.citations[1].kind == .note) + } + + @Test("throws on non-2xx response") + func throwsOnError() async throws { + StubProtocol.status = 502 + StubProtocol.responseBody = #"{"error":"chat_failed"}"#.data(using: .utf8) + let adapter = LiveChatAdapter(baseURL: URL(string: "https://api.example.com")!, session: makeSession()) + await #expect(throws: Error.self) { + _ = try await adapter.send(scope: .talk(UUID()), messages: [ChatTurn(role: "user", content: "?")]) + } + // Reset for the next test in the suite. + StubProtocol.status = 200 + } +} +``` + +- [ ] **Step 2: Implement `LiveChatAdapter`** + +```swift +// +// LiveChatAdapter.swift +// Muesli +// +// ChatPort live adapter — talks to /v1/sessions/:id/chat (talk scope) and +// /v1/chat (multi-session conference scope). Wraps URLSession; the API +// base URL comes from APIConfiguration so dev/staging routing stays in +// one place. +// + +import Foundation + +struct LiveChatAdapter: ChatPort, @unchecked Sendable { + let baseURL: URL + let session: URLSession + + /// Resolver mapping a conference UUID to the list of backend session + /// IDs that belong to it. The default reaches into the live SwiftData + /// container; tests inject a synchronous closure. + var sessionIdsResolver: (UUID) async throws -> [UUID] + + init( + baseURL: URL, + session: URLSession = .shared, + sessionIdsResolver: @escaping (UUID) async throws -> [UUID] = { _ in [] } + ) { + self.baseURL = baseURL + self.session = session + self.sessionIdsResolver = sessionIdsResolver + } + + func send(scope: ChatScope, messages: [ChatTurn]) async throws -> ChatResponse { + return try await send(scope: scope, messages: messages, sessionIdsResolver: self.sessionIdsResolver) + } + + /// Explicit-resolver variant used by tests to bypass SwiftData. + func send( + scope: ChatScope, + messages: [ChatTurn], + sessionIdsResolver: (UUID) async throws -> [UUID] + ) async throws -> ChatResponse { + var request: URLRequest + let encoder = JSONEncoder() + + switch scope { + case .talk(let id): + request = URLRequest(url: baseURL.appendingPathComponent("/v1/sessions/\(id.uuidString)/chat")) + struct TalkBody: Encodable { let messages: [ChatTurn] } + request.httpBody = try encoder.encode(TalkBody(messages: messages)) + case .conference(let id): + let sessionIds = try await sessionIdsResolver(id) + request = URLRequest(url: baseURL.appendingPathComponent("/v1/chat")) + struct ConfBody: Encodable { let sessionIds: [UUID]; let messages: [ChatTurn] } + request.httpBody = try encoder.encode(ConfBody(sessionIds: sessionIds, messages: messages)) + } + + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw ChatAdapterError.http(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1, body: data) + } + struct Envelope: Decodable { + struct Usage: Decodable { let tokensIn: Int; let tokensOut: Int } + let message: ChatTurn + let citations: [ChatCitation] + let usage: Usage + } + let env = try JSONDecoder().decode(Envelope.self, from: data) + return ChatResponse(message: env.message, citations: env.citations) + } +} + +enum ChatAdapterError: Error, LocalizedError { + case http(statusCode: Int, body: Data) + + var errorDescription: String? { + switch self { + case .http(let code, _): return "Chat request failed (HTTP \(code))." + } + } +} +``` + +- [ ] **Step 3: Wire into `World.live`** + +In `World.swift`, replace `chat: UnimplementedChatAdapter()` with: + +```swift +chat: LiveChatAdapter( + baseURL: URL(string: "https://staging-api.muesli-app.com/api/v1")!, + session: .shared, + sessionIdsResolver: { conferenceId in + // Resolved at call-time by ChatViewModel from SwiftData; the + // default returns empty so the World composition doesn't depend + // on a ModelContainer here. ChatViewModel pre-resolves and + // injects via the explicit-resolver variant. + return [] + } +) +``` + +- [ ] **Step 4: Run tests + build** + +- [ ] **Step 5: Commit** + +```bash +git add src/mobile/Muesli/Adapters/LiveChatAdapter.swift \ + src/mobile/Muesli/World.swift \ + src/mobile/MuesliTests/Adapters/LiveChatAdapterTests.swift +git commit -m "feat(ios): LiveChatAdapter wires ChatPort to /v1 chat routes + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 2: `ChatViewModel` + +**Files:** +- Create: `src/mobile/Muesli/ViewModels/ChatViewModel.swift` +- Test: `src/mobile/MuesliTests/ViewModels/ChatViewModelTests.swift` + +- [ ] **Step 1: Failing tests** + +```swift +// +// ChatViewModelTests.swift +// + +import Testing +import Foundation +import SwiftData +@testable import Muesli + +@Suite("Chat View Model Tests", .tags(.unit)) +struct ChatViewModelTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + final class StubChat: ChatPort, @unchecked Sendable { + var stub: ChatResponse = ChatResponse( + message: ChatTurn(role: "assistant", content: "ok"), + citations: [] + ) + private(set) var calls: [(ChatScope, [ChatTurn])] = [] + func send(scope: ChatScope, messages: [ChatTurn]) async throws -> ChatResponse { + calls.append((scope, messages)) + return stub + } + } + + @Test("send persists user + assistant messages to the ChatThread") + @MainActor + func sendPersists() async throws { + let container = try makeContainer() + let context = container.mainContext + let stub = StubChat() + let thread = ChatThread(scopeKind: .talk, scopeId: UUID()) + context.insert(thread) + try context.save() + + let vm = ChatViewModel(thread: thread, chat: stub, context: context) + try await vm.send(content: "hi") + + let messages = thread.messages.sorted { $0.createdAt < $1.createdAt } + #expect(messages.count == 2) + #expect(messages[0].role == .user) + #expect(messages[0].content == "hi") + #expect(messages[1].role == .assistant) + #expect(messages[1].content == "ok") + } + + @Test("send rolls back the optimistic user message on failure") + @MainActor + func sendRollsBackOnFailure() async throws { + let container = try makeContainer() + let context = container.mainContext + struct ThrowingChat: ChatPort, @unchecked Sendable { + func send(scope: ChatScope, messages: [ChatTurn]) async throws -> ChatResponse { + throw NSError(domain: "test", code: 1) + } + } + let thread = ChatThread(scopeKind: .talk, scopeId: UUID()) + context.insert(thread) + try context.save() + let vm = ChatViewModel(thread: thread, chat: ThrowingChat(), context: context) + await #expect(throws: Error.self) { + try await vm.send(content: "hi") + } + #expect(thread.messages.isEmpty) + } + + @Test("send encodes citations onto the assistant message") + @MainActor + func sendCarriesCitations() async throws { + let container = try makeContainer() + let context = container.mainContext + let stub = StubChat() + stub.stub = ChatResponse( + message: ChatTurn(role: "assistant", content: "see"), + citations: [ChatCitation(kind: .note, talkId: nil, noteId: UUID(), startSec: nil, endSec: nil, label: nil, title: "T")] + ) + let thread = ChatThread(scopeKind: .talk, scopeId: UUID()) + context.insert(thread) + try context.save() + let vm = ChatViewModel(thread: thread, chat: stub, context: context) + try await vm.send(content: "?") + + let assistant = thread.messages.first { $0.role == .assistant } + let citations = (assistant?.citationsJSON).flatMap { + try? JSONDecoder().decode([ChatCitation].self, from: $0) + } + #expect(citations?.count == 1) + #expect(citations?.first?.kind == .note) + } +} +``` + +- [ ] **Step 2: Implement `ChatViewModel`** + +```swift +// +// ChatViewModel.swift +// Muesli +// +// Owns one ChatThread's send loop. Appends the user message, calls the +// ChatPort, then appends the assistant message with citations. Rolls +// back the user message if the port throws so the thread doesn't show +// an orphan turn. +// + +import Foundation +import SwiftData + +@MainActor +@Observable +final class ChatViewModel { + let thread: ChatThread + let chat: any ChatPort + let context: ModelContext + + private(set) var isSending = false + private(set) var lastError: String? + + init(thread: ChatThread, chat: any ChatPort, context: ModelContext) { + self.thread = thread + self.chat = chat + self.context = context + } + + var messagesSorted: [ChatMessage] { + thread.messages.sorted { $0.createdAt < $1.createdAt } + } + + func send(content: String) async throws { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + isSending = true + lastError = nil + + let userMsg = ChatMessage(role: .user, content: trimmed, createdAt: Date(), thread: thread) + context.insert(userMsg) + thread.messages.append(userMsg) + try? context.save() + + let scope: ChatScope = (thread.scopeKind == .talk) + ? .talk(thread.scopeId) + : .conference(thread.scopeId) + + let history = messagesSorted.map { ChatTurn(role: $0.role.rawValue, content: $0.content) } + + do { + let response = try await chat.send(scope: scope, messages: history) + let assistantMsg = ChatMessage( + role: .assistant, + content: response.message.content, + citationsJSON: try? JSONEncoder().encode(response.citations), + createdAt: Date(), + thread: thread + ) + context.insert(assistantMsg) + thread.messages.append(assistantMsg) + thread.updatedAt = Date() + try? context.save() + isSending = false + } catch { + // Roll back the user message so the thread doesn't show an orphan turn. + context.delete(userMsg) + thread.messages.removeAll { $0.id == userMsg.id } + try? context.save() + isSending = false + lastError = error.localizedDescription + throw error + } + } +} +``` + +- [ ] **Step 3: Run tests, expect PASS, commit** + +```bash +git add src/mobile/Muesli/ViewModels/ChatViewModel.swift \ + src/mobile/MuesliTests/ViewModels/ChatViewModelTests.swift +git commit -m "feat(ios): ChatViewModel — append user / call port / append assistant + +Co-Authored-By: Claude Opus 4.7 (1M context) " +``` + +--- + +## Task 3: `CitationChip` component + +**Files:** +- Create: `src/mobile/Muesli/Views/Components/CitationChip.swift` + +```swift +// +// CitationChip.swift +// Muesli +// +// Pill-shaped citation reference attached below an assistant message. +// Transcript citations show mm:ss; note citations show the note title. +// + +import SwiftUI + +struct CitationChip: View { + let citation: ChatCitation + var onTap: () -> Void = {} + + var body: some View { + Button(action: onTap) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.caption2) + Text(label) + .font(.caption.weight(.medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.accentColor.opacity(0.12)) + .foregroundColor(.accentColor) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private var icon: String { + switch citation.kind { + case .transcript: return "clock" + case .note: return "doc.text" + } + } + + private var label: String { + switch citation.kind { + case .transcript: return citation.label ?? "Transcript" + case .note: return citation.title ?? "Note" + } + } +} +``` + +Commit alone. + +--- + +## Task 4: `ChatView` + +**Files:** +- Create: `src/mobile/Muesli/Views/ChatView.swift` +- Modify: `src/mobile/Muesli/Views/ConferenceDetailView.swift` — wire button +- Modify: `src/mobile/Muesli/Views/AugmentedNoteView.swift` — add chat button + +`ChatView` body in outline: +- Scope chip at top (read from thread.scopeKind / resolves to title via lookup) +- Scrolling list of message bubbles with citation chips below assistants +- Send row with text field + send button (disabled while in-flight) +- `onTap` per citation chip: + - `.transcript` → presents `ChapteredPlaybackView(note:startAt:)` if the talkId resolves to a local Note + - `.note` → pushes the noteId's `AugmentedNoteView` (uses `NavigationLink(value:)` if inside a NavigationStack, else dismisses to root and notifies — for v1 we present as a sheet over current navigation) + +Wire from: +- `ConferenceDetailView`: replace the disabled button with one that fetches-or-creates a `ChatThread` for this conference and presents `ChatView`. Implementation finds the thread via `FetchDescriptor(predicate: #Predicate { $0.scopeKindRaw == "conference" && $0.scopeId == conference.id })`; creates a new one if none exists. +- `AugmentedNoteView`: add a toolbar `Ask` button that does the same for `talk(note.id)`. + +Commit Task 4 along with the wiring of both call sites. + +--- + +## Phase 6 done when + +- Four tasks committed. +- `LiveChatAdapterTests` and `ChatViewModelTests` green. +- Build green. +- Simulator smoke: open conference detail → Chat with this conference → type a question → assistant reply appears with citation chips. Same flow from an AugmentedNote → Ask. + +## Next plan + +Phase 7: `NewNoteView` polish + `WaveformView` rework. diff --git a/docs/superpowers/plans/2026-05-12-phase-7-10-polish-and-cleanup.md b/docs/superpowers/plans/2026-05-12-phase-7-10-polish-and-cleanup.md new file mode 100644 index 0000000..a906952 --- /dev/null +++ b/docs/superpowers/plans/2026-05-12-phase-7-10-polish-and-cleanup.md @@ -0,0 +1,65 @@ +# Phases 7-10 (Combined): Polish, Live Activity, Cleanup, Sample Data + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. + +**Goal:** Land the final four phases as one PR-end push: +- **Phase 7** — `NewNoteView` polish + `WaveformView` rework so the recording screen matches the mockup. +- **Phase 8** — ActivityKit Live Activity scaffolding for background recording / Dynamic Island. Includes the `ActivityAttributes` type, `AudioRecordingManager` hooks, and Info.plist background mode. The Live Activity widget extension target itself must be added in Xcode (cannot be created from code); this PR ships the in-app scaffolding so Adding That Target is the only remaining manual step. +- **Phase 9** — Delete the orphaned views replaced by Phases 3-4 (`SimpleMainView`, `SimpleNoteDetailView`, `AISummaryEditorView`, `EnhancedNoteEditorView`, `MyNotesView`). Their tests come along. +- **Phase 10** — Refresh `SampleDataManager` so seeded notes carry a `backendSessionId`. Manual smoke checklist. + +**Spec reference:** `docs/superpowers/specs/2026-05-12-gap-close-design.md` § Scenes ii, iii, and § Salvage, Cleanup, Testing. + +--- + +## Phase 7 — Recording polish + +**WaveformView:** widen from 5 bars to a denser 24-bar set with mockup-matching heights derived from `audioLevel`. Replace the solid-green fill with a colour that adapts to the system theme. + +**NewNoteView controls:** the existing record button is fine; promote the stop affordance to a square `stop.fill` icon for the mockup look. Confirm Pause is still reachable. + +Both edits are visual; no unit-test additions (the existing live-update logic is unchanged). + +## Phase 8 — Live Activity scaffolding + +**Files (production):** +- `src/mobile/Muesli/LiveActivity/RecordingActivityAttributes.swift` — `ActivityAttributes` shared with the widget extension once it's added. +- `src/mobile/Muesli/LiveActivity/LiveActivityController.swift` — `@MainActor` controller that `Activity.request` on start, `update` every second, `end` on stop. Wraps `if #available(iOS 16.2, *)` and `ActivityAuthorizationInfo().areActivitiesEnabled` so the integration is a no-op when the user has disabled them. +- `src/mobile/Muesli/AudioRecordingManager.swift` — call `LiveActivityController.shared.start/update/end` from the recording lifecycle. Guarded for DEBUG and gracefully degraded if `areActivitiesEnabled == false`. + +**Info.plist:** add `UIBackgroundModes` with `audio` so the recording survives backgrounding. + +**Cannot do from code:** adding the Widget Extension target itself. The actual `RecordingActivity` Live Activity UI lives in that target; we drop a placeholder doc-comment in `RecordingActivityAttributes.swift` pointing the reader at how to add the target. Until then the controller's `start()` returns gracefully (`areActivitiesEnabled` returns false without a hosting extension). + +## Phase 9 — Salvage cleanup + +**Delete (after confirming nothing references them):** +- `src/mobile/Muesli/Views/SimpleMainView.swift` (replaced by `MainView`) +- `src/mobile/Muesli/Views/SimpleNoteDetailView.swift` (replaced by `AugmentedNoteView`) +- `src/mobile/Muesli/Views/AISummaryEditorView.swift` (no consumer) +- `src/mobile/Muesli/Views/EnhancedNoteEditorView.swift` (no consumer) +- `src/mobile/Muesli/Views/MyNotesView.swift` (no consumer) +- Matching tests: `MuesliTests/Views/AISummaryEditorViewTests.swift`, `MuesliTests/Views/EnhancedNoteEditorViewTests.swift`, `MuesliTests/Views/NewNoteViewFallbackTests.swift`'s SimpleMain references, `MuesliTests/Views/SimpleMainViewFallbackTests.swift` + +After deletion: `grep -r SimpleMainView src/` to confirm no dangling references. Build + run tests to confirm the suite still passes. + +## Phase 10 — Sample data + smoke + +`SampleDataManager.generateSampleNotes` now sets `backendSessionId = note.id` for every seeded talk so chat works in debug builds against a backend that has matching seed sessions (or just to verify the iOS-side flow with the API stubbed/down). + +Manual smoke checklist: +- Cold launch shows `MainView` with `DataSummit 2026` and `DevWorld 2026` sections. +- Tap a talk → AugmentedNoteView renders. (Sample data has no `blendedMarkdown`, so the blend-status fallback shows.) +- Tap conference header → ConferenceDetailView shows hero + talks list + active Chat button. +- Tap Chat → ChatView opens with the scope chip. +- Tap Listen on a note with audio → ChapteredPlaybackView appears. +- Background the app while recording → Dynamic Island banner (only if widget extension target was added; otherwise the app keeps recording but the banner doesn't appear). + +--- + +## Done when + +- All four phases committed. +- iOS test suite passes (the deleted view tests should be the only delta). +- Build + lint clean. +- Final cross-task review captures any drift. diff --git a/docs/superpowers/specs/2026-05-12-gap-close-design.md b/docs/superpowers/specs/2026-05-12-gap-close-design.md new file mode 100644 index 0000000..ca95a6b --- /dev/null +++ b/docs/superpowers/specs/2026-05-12-gap-close-design.md @@ -0,0 +1,287 @@ +# Gap Close: Mockup → Implementation + +**Date:** 2026-05-12 +**Author:** Travis Frisinger (with Claude) +**Status:** Approved for planning +**Delivery shape:** Single PR (per user direction). Internal commit stacking by phase for review legibility. + +## Goal + +Close the gap between `mockups/flow.html` and the current SwiftUI/Node implementation so all nine scenes of the design ship. + +Today the skeleton (notes list, recording, basic detail, image capture) is present, but the high-value scenes (augmented note rendering, conference grouping, chaptered scrubber, chat) are missing or stubbed. The backend AI pipeline (`blendService`, `chapterizeService`, `anthropic.js`) is largely built and `Note` already persists the structured outputs (`blendedMarkdown`, parallel citation arrays, chapters JSON). The remaining work is mostly iOS rendering plus a new chat backend. + +## Scope + +In scope (every scene in `mockups/flow.html`): + +1. Scene i, Notes list grouped by conference +2. Scene ii, Recording UI polish +3. Scene iii, Background recording + Dynamic Island Live Activity +4. Scene iv, In-app camera with captured-slide strip +5. Scene v, Processing/blending overlay state +6. Scene vi, Augmented note view (the flagship) +7. Scene vii, Conference detail +8. Scene viii, Chat sheet (talk + conference scopes, with citations) +9. Scene ix, Chaptered playback scrubber + +Out of scope (explicit non-goals, follow-on candidates): + +- iCloud sync, multi-day folders, calendar import +- Embedding-based retrieval for chat (token-budget heuristic is the v1) +- Streaming chat responses (single-shot in v1) +- Server-side chat history persistence (client-only via SwiftData) +- User-visible chat cost display (logged server-side only) +- iOS test coverage gate (no gate added in this PR) + +## Architecture Overview + +### Data model (iOS / SwiftData) + +New entities: + +```swift +@Model final class Conference { + var id: UUID + var name: String + var location: String? + var startDate: Date? + var endDate: Date? + var conferenceDescription: String? + var createdAt: Date + @Relationship(deleteRule: .nullify, inverse: \Note.conference) + var notes: [Note] = [] +} + +@Model final class ChatThread { + var id: UUID + var scopeKind: String // "talk" | "conference" + var scopeId: UUID + var createdAt: Date + var updatedAt: Date + @Relationship(deleteRule: .cascade, inverse: \ChatMessage.thread) + var messages: [ChatMessage] = [] +} + +@Model final class ChatMessage { + var id: UUID + var role: String // "user" | "assistant" + var content: String + var citationsJSON: Data? + var createdAt: Date + var thread: ChatThread? +} +``` + +Changes to `Note`: + +- Add `var speaker: String?` +- Add `var conference: Conference?` relationship +- Keep `var conferenceName: String?` for one release as a fallback. New reads go through `conference?.name`. Removable in a follow-on. + +### Schema versioning + migration + +Use SwiftData `VersionedSchema` chain `SchemaV1 → SchemaV2`. New entities and new optional fields are additive (lightweight migration). + +`ConferenceMigration.swift` runs once at app launch: + +1. Query `Note` where `conference == nil && conferenceName != nil`. +2. Group by trimmed, case-insensitive `conferenceName`. +3. For each group, find-or-create a `Conference` with that name. Backfill `startDate` and `endDate` from min/max `note.timestamp`. Other metadata stays nil. +4. Attach `note.conference = conference` for each note in the group. +5. Save. Mark migration complete via `UserDefaults` keyed by schema version so it does not re-run. + +Idempotent and additive. The original `conferenceName` stays intact so a botched migration is recoverable. + +`SampleDataManager` updated to seed two conferences with multi-talk groupings so debug builds exercise the new screens. + +### Backend additions + +Two new routes, mounted under existing JWT auth: + +``` +POST /sessions/:sessionId/chat +POST /conferences/:conferenceId/chat +``` + +Request: `{ messages: [{ role, content }, ...] }` (full thread, server is stateless). +Response: `{ message, citations[], usage }`. + +Citation shape: + +```json +{ "kind": "transcript", "talkId": "uuid", "startSec": 612.4, "endSec": 624.1, "label": "10:12" } +{ "kind": "note", "noteId": "uuid", "title": "The three pillars" } +``` + +`chatService.js` exports `chatTalk(...)` and `chatConference(...)` which delegate to a shared `runChat({ context, messages })`. Context assembly: + +- Talk scope: transcript, userNotes, blendedMarkdown, photo OCR summaries for one session. +- Conference scope: for each session under the conference, include a compact summary (title, speaker, date, aiSummary, top OCR snippets). For the 3 most-recent talks (or whatever fits the token budget), include full `blendedMarkdown`. Older talks degrade to summary-only. + +System prompt instructs Sonnet to: + +- Answer only from supplied context. If unknown, say so. +- Emit citations inline as `[[c:N]]` tokens referencing entries in a `references[]` array it also returns. +- Output strict JSON: `{ answer, references }`. + +Server post-processing strips `[[c:N]]` tokens from `answer`, resolves references into the `citations[]` returned to the client (formatting `startSec` as `mm:ss`, resolving note titles), and drops references that fail to resolve. + +Guardrails: + +- Token budget cap at ~150k input tokens for conference scope. +- `max_tokens` 2000 per turn. +- Rate limit via a new `chatLimiter` key reusing the transcription-tier rate (20 / 15min). +- Authz check confirms the requesting user owns the session or conference before responding. +- Cost tracking via existing `ledgerService` pattern. Not surfaced to users in v1. + +### iOS view restructuring + +Delete after salvage: + +- `SimpleMainView.swift`, `SimpleNoteDetailView.swift` +- `AISummaryEditorView.swift` (salvage edit UX into augmented note overflow sheet) +- `EnhancedNoteEditorView.swift` (salvage formatting toolbar into inline user-notes editor) +- `MyNotesView.swift` (fold pattern into augmented note) + +Rename and restyle: + +- `SimpleArchiveView` → `ArchiveView` +- `SimpleSettingsView` → `SettingsView` + +New views: + +- `MainView` (Scene i) +- `ConferenceDetailView` (Scene vii) +- `AugmentedNoteView` (Scene vi) +- `ChapteredPlaybackView` (Scene ix) +- `ChatView` (Scene viii) +- `BlendingOverlay` (Scene v overlay) + +Polished in place: + +- `NewNoteView` (Scenes ii, iv) +- `WaveformView` (animated bars) + +New components: + +- `SlideCard`, `ScopeChip`, `CitationChip`, `ChapterScrubber` + +Dev-only views (`DebugMenuView`, `DeveloperSettingsView`, `PerformanceView`, `TranscriptView`) stay, but navigation entry points get wrapped in `#if DEBUG`. + +### Background recording (Scene iii) + +ActivityKit Live Activity with `RecordingAttributes { startedAt, sessionId, title }`. + +- Compact leading: red dot. Compact trailing: `mm:ss`. +- Expanded: title, elapsed time, Stop button. +- `AudioRecordingManager` calls `Activity.update(...)` every 1s while recording is active. +- Info.plist gains `UIBackgroundModes: audio` so recording survives backgrounding. +- Tap on Dynamic Island deep-links back to the active `NewNoteView`. + +## Component design: Augmented note renderer + +This is the flagship and the most subtle piece. Detailed because the renderer is the most testable and most error-prone new code. + +Inputs (already persisted on `Note`): + +- `blendedMarkdown: String?` +- `blendCitationsJSON: Data?` decodes to `BlendCitations { userNoteSpans, quoteSpans, imagePlacements, citations }`. All four are parallel arrays of char ranges into `blendedMarkdown`. +- `photos: [Photo]` for thumbnail lookup keyed by `photoId` referenced in `imagePlacements`. +- `chaptersJSON: Data?` for "Listen" jump targets. + +Render strategy: + +1. Parse `blendedMarkdown` into a base `AttributedString` via SwiftUI's markdown initializer. +2. Walk `userNoteSpans` and apply a `.bold()` + accent foreground attribute over each range. +3. Walk `quoteSpans` and apply a quote-block paragraph style (left bar, italic). Attach a custom attribute carrying `startSec` so a tap gesture can read it. +4. Walk `citations` and apply a subtle underline + custom attribute carrying the cited transcript range. +5. For rendering photos at `imagePlacements`, split the `blendedMarkdown` at each offset (ascending) into text segments. Build a `[BlendSegment]` list alternating `.text(AttributedString)` and `.photo(Photo, caption)`. The view body iterates segments in a `VStack` so photos render as full-width cards between paragraphs rather than as inline runs. + +Tap handling: + +- Tap on a `quoteSpan` or `citation` opens `ChapteredPlaybackView` for this note at `startSec`. +- Photos open `FullscreenImageViewer` (existing component). + +Edge cases the renderer must handle (each becomes a unit test): + +- `blendedMarkdown` missing or `blendStatus != .complete`: show `BlendingOverlay` or failed state with retry. +- Empty arrays for any of the four parallel structures: render bare markdown without overlays. +- Out-of-range char offsets (defensive against bad model output): clamp and log, do not crash. +- Image placements pointing at a `photoId` no longer in `note.photos`: skip the photo card silently. +- Multiple spans overlapping: last-applied wins; document this in renderer. + +## Salvage map + +| From | Take | Into | +|---|---|---| +| `AISummaryEditorView` | Summary edit field + save flow | `AugmentedNoteView` overflow → "Edit AI summary" sheet | +| `EnhancedNoteEditorView` | Formatting toolbar component | `AugmentedNoteView` inline `userNotes` editor | +| `MyNotesView` | Personal-notes edit affordance pattern | folded into `AugmentedNoteView` | + +## Testing strategy + +### API (Jest, existing infra, 70% gate per commit 3a5a0b9) + +Unit: + +- `chatService.test.js`: context assembly per scope; citation post-processing (token strip + reference resolution); JSON parse failure handling; reference-resolve failure drops the citation; token budget fallback for conference scope. Mock anthropic client like existing `blendService.test.js`. + +Integration: + +- `chat.routes.test.js`: auth gating (no token → 401), ownership check (wrong user → 403), rate limit hit, happy path with mocked anthropic for both scopes. + +### iOS (XCTest) + +Unit: + +- `BlendRendererTests`: synthetic `blendedMarkdown` + parallel arrays, assert ranges are styled and segment list inserts photos at correct offsets. Covers all edge cases listed above. +- `ConferenceMigrationTests`: fixture of notes with `conferenceName` strings, assert correct `Conference` records created and notes attached. Run migration twice, assert idempotency. +- `ChatServiceTests`: request shape, citation decoding, thread persistence. Mock URLSession. + +UI: + +- Smoke test through main → conference → note → scrubber → chat sheet. Navigation graph only, no visual assertions. + +## Ready-to-ship checklist + +Before marking the PR ready for review: + +- `./scripts/lint.sh` clean +- `./scripts/test.sh all` green +- `cd src/api && npm test` green at 70%+ coverage +- Manual smoke on simulator covering: record, blend pipeline runs to `.complete`, augmented note renders with overlays, conference grouping shows on main, scrubber seeks and respects chapters, chat works for both scopes with tappable citations, background recording survives backgrounding with Dynamic Island + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Single mega-PR is hard to review and revert | Stack commits inside the PR by phase so reviewers can walk scene-by-scene. Feature-flag chat routes so prod can disable without revert. | +| SwiftData migration on real user data is destructive if wrong | Migration is additive (keeps `conferenceName` field), idempotent (guarded by UserDefaults key), and write-on-launch (no migration during normal use). | +| Sonnet returning malformed JSON for chat | `chatService` mirrors `blendService` parse-or-throw pattern. Route returns 502 with a user-facing message; iOS surfaces a retry. | +| Conference-scope chat exceeds token budget | Heuristic (summaries + N recent full blends) implemented in `chatService` with explicit cap. Embedding retrieval is the v2 path. | +| Live Activity not supported on older devices | Feature-gate on `ActivityKit.Activity.activitiesEnabled`. Graceful fallback to a plain in-app banner. | +| Augmented note renderer crashes on bad span offsets | Defensive clamping + logging. Unit tests cover overlapping/out-of-range spans. | + +## Phased implementation order (within the single PR) + +This order minimizes time spent on broken intermediate states. + +1. **Schema + migration** (additive, no UI yet) +2. **Backend `chatService` + routes + tests** (independently testable; feature-flagged) +3. **`BlendRenderer` + `AugmentedNoteView`** (highest test value, unblocks most scenes). Salvaged UX from `AISummaryEditorView` and `EnhancedNoteEditorView` lands here. +4. **`MainView` + `ConferenceDetailView`** (depends on schema). Renames of `Simple*Archive`/`Settings` happen here. +5. **`ChapteredPlaybackView`** (depends on renderer's citation taps) +6. **`ChatView` + iOS `ChatService`** (depends on backend) +7. **`NewNoteView` polish + `WaveformView` rework** +8. **Live Activity / Dynamic Island** +9. **Delete orphaned view files** (`AISummaryEditorView`, `EnhancedNoteEditorView`, `MyNotesView`, `SimpleNoteDetailView`, `SimpleMainView`) once their salvage targets are in place +10. **Sample data refresh, manual smoke, ship** + +## Open follow-ons (post-merge) + +- Remove `Note.conferenceName` after one release. +- Embedding-based retrieval for conference-scope chat as corpus grows. +- Streaming chat responses if perceived latency is an issue. +- iOS code coverage gate. +- Server-side chat persistence + sync. diff --git a/scripts/add-live-activity-target.rb b/scripts/add-live-activity-target.rb new file mode 100644 index 0000000..8fc4ab9 --- /dev/null +++ b/scripts/add-live-activity-target.rb @@ -0,0 +1,138 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Adds the MuesliRecordingLiveActivity Widget Extension target to +# src/mobile/Muesli.xcodeproj. Run once after pulling the branch. +# +# Usage: +# GEM_HOME=$HOME/.gem/ruby/2.6.0 ruby scripts/add-live-activity-target.rb +# +# Idempotent: if the target already exists, the script is a no-op. + +$LOAD_PATH.unshift(*Dir.glob("#{ENV.fetch('HOME')}/.gem/ruby/*/gems/*/lib")) +require 'xcodeproj' + +PROJECT_PATH = File.expand_path('../src/mobile/Muesli.xcodeproj', __dir__) +TARGET_NAME = 'MuesliRecordingLiveActivity' +EXT_DIR_REL = "#{TARGET_NAME}/" +EXT_DIR_ABS = File.expand_path("../src/mobile/#{TARGET_NAME}", __dir__) +SHARED_ATTRS_FILE = 'Muesli/LiveActivity/RecordingActivityAttributes.swift' +SHARED_ATTRS_NAME = 'RecordingActivityAttributes.swift' + +abort "Project not found at #{PROJECT_PATH}" unless Dir.exist?(PROJECT_PATH) + +project = Xcodeproj::Project.open(PROJECT_PATH) +app_target = project.targets.find { |t| t.name == 'Muesli' } +abort "Main 'Muesli' target not found" unless app_target + +if project.targets.any? { |t| t.name == TARGET_NAME } + puts "Target #{TARGET_NAME} already exists; nothing to do." + exit 0 +end + +# Build settings derived from the main app target — keep deployment, bundle +# identifier prefix, and Swift version consistent. +app_release_config = app_target.build_configurations.find { |c| c.name == 'Release' } +app_debug_config = app_target.build_configurations.find { |c| c.name == 'Debug' } +deployment = app_release_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] || '16.2' +swift_version = app_release_config.build_settings['SWIFT_VERSION'] || '5.0' +bundle_prefix = (app_release_config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] || 'dev.koderex.Muesli').dup +dev_team = app_release_config.build_settings['DEVELOPMENT_TEAM'] + +# Create the extension target. +ext_target = project.new_target( + :app_extension, + TARGET_NAME, + :ios, + deployment, + project.products_group, + :swift +) + +# Pin product type so SwiftUI Live Activities recognize it. +ext_target.product_type = 'com.apple.product-type.app-extension' + +# Group for the extension's sources. +ext_group = project.main_group.find_subpath(TARGET_NAME, true) +ext_group.set_source_tree('') +ext_group.set_path(TARGET_NAME) + +# Add the two Swift sources owned by the extension. +%w[MuesliRecordingLiveActivityBundle.swift RecordingActivityWidget.swift].each do |fname| + abspath = File.join(EXT_DIR_ABS, fname) + abort "Missing extension source: #{abspath}" unless File.exist?(abspath) + file_ref = ext_group.new_file(fname) + ext_target.add_file_references([file_ref]) +end + +# Share the ActivityAttributes type with the main app by also building it +# into the extension. The main app uses a filesystem-synchronized root +# group, so the shared file isn't in the pbxproj as a regular PBXFileReference. +# Add a separate reference pointing at the same path on disk. +shared_group = ext_group +shared_ref = shared_group.find_file_by_path('SharedRecordingActivityAttributes.swift') +unless shared_ref + shared_ref = shared_group.new_reference( + File.expand_path('../src/mobile/Muesli/LiveActivity/RecordingActivityAttributes.swift', __dir__) + ) + shared_ref.name = 'RecordingActivityAttributes.swift' +end +ext_target.add_file_references([shared_ref]) + +# Info.plist for the extension. +info_plist_rel = "#{TARGET_NAME}/Info.plist" +info_plist_ref = ext_group.find_file_by_path('Info.plist') || ext_group.new_file('Info.plist') + +# Set build settings on each configuration. +ext_target.build_configurations.each do |config| + bs = config.build_settings + bs['PRODUCT_NAME'] = '$(TARGET_NAME)' + bs['PRODUCT_BUNDLE_IDENTIFIER'] = "#{bundle_prefix}.#{TARGET_NAME}" + bs['INFOPLIST_FILE'] = info_plist_rel + bs['IPHONEOS_DEPLOYMENT_TARGET'] = deployment + bs['SWIFT_VERSION'] = swift_version + bs['SKIP_INSTALL'] = 'YES' + bs['CODE_SIGN_STYLE'] = 'Automatic' + bs['DEVELOPMENT_TEAM'] = dev_team if dev_team + bs['GENERATE_INFOPLIST_FILE'] = 'NO' + bs['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks', '@executable_path/../../Frameworks'] + bs['SWIFT_EMIT_LOC_STRINGS'] = 'YES' + bs['CURRENT_PROJECT_VERSION'] = '1' + bs['MARKETING_VERSION'] = '1.0' + bs['ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME'] = 'AccentColor' + bs['ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME'] = 'WidgetBackground' +end + +# Make the app target depend on the extension and embed it. +app_target.add_dependency(ext_target) + +embed_phase = app_target.copy_files_build_phases.find do |p| + p.symbol_dst_subfolder_spec == :plug_ins +end +embed_phase ||= app_target.new_copy_files_build_phase('Embed App Extensions').tap do |p| + p.symbol_dst_subfolder_spec = :plug_ins + p.dst_path = '' +end +already_embedded = embed_phase.files_references.any? { |r| r&.path&.include?(TARGET_NAME) } +unless already_embedded + build_file = embed_phase.add_file_reference(ext_target.product_reference) + build_file.settings = { 'ATTRIBUTES' => ['RemoveHeadersOnCopy'] } +end + +# Make sure the main app declares Live Activity support and the audio +# background mode so recording survives backgrounding. +app_target.build_configurations.each do |config| + bs = config.build_settings + bs['INFOPLIST_KEY_NSSupportsLiveActivities'] = 'YES' + modes = bs['INFOPLIST_KEY_UIBackgroundModes'] + current = case modes + when Array then modes.dup + when String then [modes] + else [] + end + current << 'audio' unless current.include?('audio') + bs['INFOPLIST_KEY_UIBackgroundModes'] = current +end + +project.save +puts "Added Widget Extension target '#{TARGET_NAME}'. Re-open Xcode if it's running." diff --git a/src/api/src/routes/auth.js b/src/api/src/routes/auth.js index 4417ee3..96fc098 100644 --- a/src/api/src/routes/auth.js +++ b/src/api/src/routes/auth.js @@ -4,6 +4,7 @@ import * as refresh from '../services/refreshTokensRepo.js'; import { signAccessToken } from '../services/jwtService.js'; import { verifyIdToken } from '../services/googleAuth.js'; import { requireAuth } from '../middleware/auth.js'; +import { config } from '../config/index.js'; import Logger from '../utils/logger.js'; const router = express.Router(); @@ -45,6 +46,38 @@ router.post('/logout', requireAuth, async (req, res) => { res.status(204).end(); }); +/** + * Dev sign-in. Non-production only: mints access + refresh tokens for an + * arbitrary email without going through Google's OAuth flow. The user row + * is upserted with a synthetic `dev:` googleSub so the existing + * unique constraint and signup-grant logic still works. + * + * Disabled when NODE_ENV === 'production'; returns 404 to avoid leaking + * the route's existence. + */ +router.post('/dev', async (req, res) => { + if (config.server.environment === 'production') { + return res.status(404).json({ error: 'not_found' }); + } + const { email, fullName } = req.body ?? {}; + if (typeof email !== 'string' || !email.includes('@')) { + return res.status(400).json({ error: 'email_invalid' }); + } + const user = await users.upsertByGoogleSub({ + googleSub: `dev:${email.toLowerCase()}`, + email, + fullName: fullName ?? null + }); + const accessToken = signAccessToken(user.id); + const r = await refresh.create(user.id); + Logger.info('Dev sign-in', { userId: user.id, email }); + res.json({ + accessToken, + refreshToken: r.token, + user: { id: user.id, email: user.email, fullName: user.fullName } + }); +}); + router.get('/me', requireAuth, async (req, res) => { const u = await users.findById(req.userId); if (!u) return res.status(404).json({ error: 'user_not_found' }); diff --git a/src/api/src/routes/chat.js b/src/api/src/routes/chat.js new file mode 100644 index 0000000..85e4aa2 --- /dev/null +++ b/src/api/src/routes/chat.js @@ -0,0 +1,76 @@ +/** + * /v1/chat REST route — multi-session (conference-scope) chat. + * + * The iOS client computes the list of session IDs belonging to a + * conference and sends them in the body. The server stays stateless + * about conferences. + */ + +import express from 'express'; +import { sessionsRepo } from '../services/sessionsRepo.js'; +import { chat } from '../services/chatService.js'; +import Logger from '../utils/logger.js'; + +const router = express.Router(); + +// Cap multi-session chat to a reasonable conference size. Larger groupings +// would not fit the token budget anyway and would just waste lookups. +const MAX_SESSION_IDS = 50; + +function userIdFor(req) { return req.userId ?? 'local-dev'; } + +router.post('/', express.json(), async (req, res) => { + const sessionIds = req.body?.sessionIds; + const messages = req.body?.messages; + if (!Array.isArray(sessionIds) || sessionIds.length === 0) { + return res.status(400).json({ error: 'sessionIds_required' }); + } + if (sessionIds.length > MAX_SESSION_IDS) { + return res.status(400).json({ error: 'too_many_sessions', max: MAX_SESSION_IDS }); + } + if (!Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ error: 'messages_required' }); + } + + const userId = userIdFor(req); + const sessions = []; + for (const id of sessionIds) { + const s = await sessionsRepo.getSession(id); + if (!s) return res.status(404).json({ error: 'session_not_found', sessionId: id }); + if (s.userId !== userId) return res.status(403).json({ error: 'forbidden', sessionId: id }); + sessions.push({ + id: s.id, title: null, speaker: null, + transcript: s.transcript, blendedMarkdown: s.blendedMarkdown, + aiSummary: null, + photos: s.photos.filter(p => p.extractStatus === 'complete'), + createdAt: s.createdAt + }); + } + + try { + const result = await chat({ + scope: { kind: 'conference', sessionIds }, + messages, + sessions + }); + await sessionsRepo.recordCost({ + userId, sessionId: sessionIds[0], microsDelta: 0, reason: 'chat_conference', + metadata: { + sessionCount: sessionIds.length, + tokensIn: result.tokensIn, + tokensOut: result.tokensOut, + citations: result.citations.length + } + }); + res.json({ + message: result.message, + citations: result.citations, + usage: { tokensIn: result.tokensIn, tokensOut: result.tokensOut } + }); + } catch (e) { + Logger.error('chat (multi-session) failed', e); + res.status(502).json({ error: 'chat_failed', detail: e.message }); + } +}); + +export default router; diff --git a/src/api/src/routes/sessions.js b/src/api/src/routes/sessions.js index 4f089de..fb4c00b 100644 --- a/src/api/src/routes/sessions.js +++ b/src/api/src/routes/sessions.js @@ -9,6 +9,8 @@ import { sessionsRepo } from '../services/sessionsRepo.js'; import { extractImage } from '../services/imageExtractService.js'; import { chapterize } from '../services/chapterizeService.js'; import { blend } from '../services/blendService.js'; +import { chat } from '../services/chatService.js'; +import { transcriptionRateLimit } from '../middleware/security.js'; import { blendCostMicros } from '../services/blendCost.js'; import { contentHash } from '../services/contentHash.js'; import * as ledger from '../services/ledgerService.js'; @@ -165,4 +167,42 @@ router.post('/:id/blend', express.json(), async (req, res) => { } }); +router.post('/:id/chat', transcriptionRateLimit, express.json(), async (req, res) => { + const id = req.params.id; + const s = await sessionsRepo.getSession(id); + if (!s) return res.status(404).json({ error: 'session_not_found' }); + if (s.userId !== userIdFor(req)) { + return res.status(403).json({ error: 'forbidden' }); + } + const messages = req.body?.messages; + if (!Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ error: 'messages_required' }); + } + try { + const result = await chat({ + scope: { kind: 'talk', sessionId: id }, + messages, + sessions: [{ + id: s.id, title: null, speaker: null, + transcript: s.transcript, blendedMarkdown: s.blendedMarkdown, + aiSummary: null, + photos: s.photos.filter(p => p.extractStatus === 'complete'), + createdAt: s.createdAt + }] + }); + await sessionsRepo.recordCost({ + userId: s.userId, sessionId: id, microsDelta: 0, reason: 'chat_talk', + metadata: { tokensIn: result.tokensIn, tokensOut: result.tokensOut, citations: result.citations.length } + }); + res.json({ + message: result.message, + citations: result.citations, + usage: { tokensIn: result.tokensIn, tokensOut: result.tokensOut } + }); + } catch (e) { + Logger.error('chat (talk) failed', e); + res.status(502).json({ error: 'chat_failed', detail: e.message }); + } +}); + export default router; diff --git a/src/api/src/server.js b/src/api/src/server.js index 9b10ade..3270d56 100644 --- a/src/api/src/server.js +++ b/src/api/src/server.js @@ -13,6 +13,7 @@ import { corsMiddleware, helmetMiddleware, generalRateLimit, + transcriptionRateLimit, slowDownMiddleware, requestIdMiddleware, requestLogger, @@ -23,6 +24,7 @@ import { import healthRoutes from './routes/health.js'; import transcriptionRoutes, { setupWebSocketServer } from './routes/transcription.js'; import sessionsRouter from './routes/sessions.js'; +import chatRouter from './routes/chat.js'; import authRouter from './routes/auth.js'; import accountRouter from './routes/account.js'; import { requireAuth } from './middleware/auth.js'; @@ -99,6 +101,8 @@ app.use('/v1/auth', authRouter); // Sessions pipeline + account (requireAuth no-ops when AUTH_ENABLED=false) app.use('/v1/sessions', requireAuth, sessionsRouter); +// Chat shares the stricter transcription-tier rate limit (per spec). +app.use('/v1/chat', requireAuth, transcriptionRateLimit, chatRouter); app.use('/v1/account', requireAuth, accountRouter); // Root endpoint diff --git a/src/api/src/services/chatService.js b/src/api/src/services/chatService.js new file mode 100644 index 0000000..fbcfbe7 --- /dev/null +++ b/src/api/src/services/chatService.js @@ -0,0 +1,144 @@ +/** + * Chat service — answers questions about one or more sessions using Sonnet. + * + * Mirrors the blendService pattern: strict JSON contract, dependency + * injection for the Anthropic client so unit tests don't reach the network. + */ + +import { anthropic, SONNET_MODEL } from './anthropic.js'; + +const SYSTEM = `You are a helpful assistant answering questions about conference talks. + +Rules: +1. Answer only from the supplied context. If you don't know, say so plainly. +2. Inline citation tokens [[c:N]] reference the N-th entry of a parallel "references" array you also return. +3. Return JSON only: + { + "answer": "...", + "references": [ + { "kind": "transcript", "sessionId": "...", "startSec": 0.0, "endSec": 0.0 } | + { "kind": "note", "sessionId": "..." } + ] + } +4. Transcript references MUST include startSec and endSec. +5. No prose outside the JSON.`; + +const REQUIRED_FIELDS = ['answer', 'references']; +const FULL_BLEND_RECENT_N = 3; + +// Approximate token budget for the input message. ~4 chars/token is the +// industry rule of thumb; we cap input at ~150k tokens ≈ 600k chars. +const INPUT_CHAR_BUDGET = 600_000; + +function compactSession(s) { + return `## Session ${s.id} — ${s.title ?? '(untitled)'} +Speaker: ${s.speaker ?? '(unknown)'} +Summary: ${s.aiSummary ?? '(none)'}`; +} + +function fullSession(s) { + const photoBlurb = (s.photos ?? []) + .map(p => `- photo ${p.photoId}: ocr="${p.ocrText ?? ''}"; desc="${p.description ?? ''}"`) + .join('\n'); + return `## Session ${s.id} — ${s.title ?? '(untitled)'} +Speaker: ${s.speaker ?? '(unknown)'} +Summary: ${s.aiSummary ?? '(none)'} +Transcript: +${s.transcript ?? '(no transcript)'} +Blended notes: +${s.blendedMarkdown ?? '(none)'} +Photos: +${photoBlurb || '(none)'}`; +} + +function buildContext(scope, sessions) { + if (scope.kind === 'talk') { + return sessions.map(fullSession).join('\n\n'); + } + // Conference scope: full blends for the N most recent sessions, demoting + // from oldest-of-the-full set if total context exceeds the budget. + const sortedDesc = [...sessions].sort((a, b) => + new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime() + ); + let fullIds = new Set(sortedDesc.slice(0, FULL_BLEND_RECENT_N).map(s => s.id)); + const render = () => + sessions.map(s => (fullIds.has(s.id) ? fullSession(s) : compactSession(s))).join('\n\n'); + + let rendered = render(); + // Demote the oldest full session until we fit. Stop if no full sessions remain. + const fullByOldestFirst = [...sortedDesc].reverse().filter(s => fullIds.has(s.id)); + while (rendered.length > INPUT_CHAR_BUDGET && fullByOldestFirst.length > 0) { + const next = fullByOldestFirst.shift(); + fullIds.delete(next.id); + rendered = render(); + } + return rendered; +} + +function stripCitationTokens(answer) { + // Only collapse whitespace adjacent to the stripped token; leave paragraph + // breaks and other whitespace alone so the answer stays readable. + return answer.replace(/[ \t]*\[\[c:\d+\]\][ \t]*/g, ' ').trim(); +} + +function resolveCitations(references, sessions) { + const byId = new Map(sessions.map(s => [s.id, s])); + const out = []; + for (const r of references) { + const s = byId.get(r.sessionId); + if (!s) continue; + if (r.kind === 'transcript' && typeof r.startSec === 'number' && typeof r.endSec === 'number') { + const mm = Math.floor(r.startSec / 60).toString().padStart(2, '0'); + const ss = Math.floor(r.startSec % 60).toString().padStart(2, '0'); + out.push({ + kind: 'transcript', + talkId: r.sessionId, + startSec: r.startSec, + endSec: r.endSec, + label: `${mm}:${ss}` + }); + } else if (r.kind === 'note') { + out.push({ kind: 'note', noteId: r.sessionId, title: s.title ?? '' }); + } + } + return out; +} + +export async function chat({ scope, messages, sessions }, deps = {}) { + const client = deps.anthropic ?? anthropic; + const context = buildContext(scope, sessions); + + const conversation = messages.map(m => `${m.role}: ${m.content}`).join('\n'); + const userMessage = `Context:\n${context}\n\nConversation so far:\n${conversation}`; + + const response = await client.messages.create({ + model: SONNET_MODEL, + max_tokens: 2000, + system: SYSTEM, + messages: [{ role: 'user', content: userMessage }] + }); + + const raw = response.content?.[0]?.text; + if (!raw) throw new Error('Empty response from Sonnet'); + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error(`Sonnet returned invalid JSON: ${raw.slice(0, 200)}`); + } + + for (const f of REQUIRED_FIELDS) { + if (!(f in parsed)) throw new Error(`Sonnet output missing required field: ${f}`); + } + + const message = { role: 'assistant', content: stripCitationTokens(parsed.answer) }; + const citations = resolveCitations(parsed.references, sessions); + + return { + message, + citations, + tokensIn: response.usage?.input_tokens ?? 0, + tokensOut: response.usage?.output_tokens ?? 0, + }; +} diff --git a/src/api/tests/integration/authFlow.test.js b/src/api/tests/integration/authFlow.test.js index 3228442..6c05972 100644 --- a/src/api/tests/integration/authFlow.test.js +++ b/src/api/tests/integration/authFlow.test.js @@ -49,6 +49,23 @@ describe('Auth flow (AUTH_ENABLED=true)', () => { expect(r.body.error).toBe('missing_token'); }); + it('dev sign-in mints tokens for a given email outside production', async () => { + const r = await request(app).post('/v1/auth/dev').send({ email: 'dev@local.test', fullName: 'Dev' }); + expect(r.status).toBe(200); + expect(r.body.accessToken).toBeTruthy(); + expect(r.body.refreshToken).toBeTruthy(); + expect(r.body.user.email).toBe('dev@local.test'); + const me = await request(app).get('/v1/auth/me').set('Authorization', `Bearer ${r.body.accessToken}`); + expect(me.status).toBe(200); + expect(me.body.user.email).toBe('dev@local.test'); + }); + + it('dev sign-in 400s on invalid email', async () => { + const r = await request(app).post('/v1/auth/dev').send({ email: 'not-an-email' }); + expect(r.status).toBe(400); + expect(r.body.error).toBe('email_invalid'); + }); + it('signs in with Google → returns access + refresh + user', async () => { const r = await request(app).post('/v1/auth/google').send({ idToken: 'fake' }); expect(r.status).toBe(200); diff --git a/src/api/tests/integration/chat.test.js b/src/api/tests/integration/chat.test.js new file mode 100644 index 0000000..9313c51 --- /dev/null +++ b/src/api/tests/integration/chat.test.js @@ -0,0 +1,183 @@ +/** + * Integration tests for chat routes. + * Mocks Anthropic and Deepgram so no real network calls occur. + */ + +import { describe, it, expect, beforeEach, jest } from '@jest/globals'; +import request from 'supertest'; + +jest.unstable_mockModule('../../src/utils/logger.js', () => ({ + default: { + info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), + health: jest.fn(), transcription: jest.fn(), websocket: jest.fn(), request: jest.fn() + } +})); + +jest.unstable_mockModule('../../src/config/index.js', () => ({ + config: { + server: { apiVersion: 'v1', environment: 'test', isDevelopment: false, isProduction: false, port: 3001 }, + health: { timeoutMs: 5000 }, + logging: { level: 'error' }, + deepgram: { model: 'nova-2', language: 'en', apiKey: 'test-key' }, + anthropic: { apiKey: 'test-key' }, + security: { + corsOrigin: 'http://localhost:3000', + rateLimiting: { windowMs: 15 * 60 * 1000, maxRequests: 1000, transcriptionMaxRequests: 1000 } + }, + upload: { maxFileSizeMB: 50, maxFileSizeBytes: 50 * 1024 * 1024, allowedMimeTypes: ['audio/mp4'] }, + websocket: { heartbeatIntervalMs: 30000, connectionTimeoutMs: 60000 }, + auth: { enabled: false, jwtSecret: 'x'.repeat(32), devUserId: 'local-dev', accessTokenTtlMin: 15, refreshTokenTtlDays: 30, googleClientId: '' }, + credits: { enforced: false, pricingVersion: 1, newUserGrantMicros: 0 }, + database: { databaseUrl: '' } + } +})); + +jest.unstable_mockModule('../../src/services/deepgramService.js', () => ({ + default: { + transcribeBuffer: jest.fn().mockResolvedValue({ transcript: 't', words: [] }), + healthCheck: jest.fn().mockResolvedValue({ healthy: true, latency: 10 }), + cleanupStaleConnections: jest.fn(), + isConnected: true, + getStats: jest.fn().mockReturnValue({ activeConnections: 0, isConnected: true }) + } +})); + +const anthropicMock = { + messages: { + create: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ answer: 'mocked answer', references: [] }) }], + usage: { input_tokens: 100, output_tokens: 20 } + }) + } +}; + +jest.unstable_mockModule('../../src/services/anthropic.js', () => ({ + anthropic: anthropicMock, + HAIKU_MODEL: 'claude-haiku-4-5-20251001', + SONNET_MODEL: 'claude-sonnet-4-6' +})); + +const { app } = await import('../../src/server.js'); +const { sessionsRepo } = await import('../../src/services/sessionsRepo.js'); + +describe('POST /v1/sessions/:id/chat', () => { + let sessionId; + + beforeEach(async () => { + anthropicMock.messages.create.mockClear(); + anthropicMock.messages.create.mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ answer: 'mocked answer', references: [] }) }], + usage: { input_tokens: 100, output_tokens: 20 } + }); + sessionId = await sessionsRepo.createSession({ userId: 'local-dev' }); + await sessionsRepo.saveTranscript(sessionId, { text: 'Sarah said hello.', words: [] }); + }); + + it('returns the assistant message and empty citations on a fresh session', async () => { + const res = await request(app) + .post(`/v1/sessions/${sessionId}/chat`) + .send({ messages: [{ role: 'user', content: 'What did Sarah say?' }] }); + expect(res.status).toBe(200); + expect(res.body.message.role).toBe('assistant'); + expect(res.body.message.content).toBe('mocked answer'); + expect(res.body.citations).toEqual([]); + expect(res.body.usage.tokensIn).toBe(100); + }); + + it('404s for unknown session', async () => { + const res = await request(app) + .post('/v1/sessions/00000000-0000-0000-0000-000000000000/chat') + .send({ messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(404); + }); + + it('400s when messages is missing', async () => { + const res = await request(app) + .post(`/v1/sessions/${sessionId}/chat`) + .send({}); + expect(res.status).toBe(400); + }); + + it('502s when chatService throws', async () => { + anthropicMock.messages.create.mockRejectedValueOnce(new Error('Sonnet down')); + const res = await request(app) + .post(`/v1/sessions/${sessionId}/chat`) + .send({ messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(502); + }); +}); + +describe('POST /v1/chat (multi-session scope)', () => { + let sess1; + let sess2; + + beforeEach(async () => { + anthropicMock.messages.create.mockClear(); + anthropicMock.messages.create.mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ answer: 'mocked answer', references: [] }) }], + usage: { input_tokens: 100, output_tokens: 20 } + }); + sess1 = await sessionsRepo.createSession({ userId: 'local-dev' }); + sess2 = await sessionsRepo.createSession({ userId: 'local-dev' }); + await sessionsRepo.saveTranscript(sess1, { text: 'talk one', words: [] }); + await sessionsRepo.saveTranscript(sess2, { text: 'talk two', words: [] }); + }); + + it('aggregates two sessions and returns assistant message', async () => { + const res = await request(app) + .post('/v1/chat') + .send({ sessionIds: [sess1, sess2], messages: [{ role: 'user', content: 'across talks' }] }); + expect(res.status).toBe(200); + expect(res.body.message.role).toBe('assistant'); + }); + + it('400s when sessionIds is missing or empty', async () => { + const res = await request(app) + .post('/v1/chat') + .send({ messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(400); + }); + + it('400s when messages is missing', async () => { + const res = await request(app) + .post('/v1/chat') + .send({ sessionIds: [sess1] }); + expect(res.status).toBe(400); + }); + + it('404s when any session is unknown', async () => { + const res = await request(app) + .post('/v1/chat') + .send({ sessionIds: [sess1, '00000000-0000-0000-0000-000000000000'], messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(404); + }); + + it('400s when sessionIds exceeds the cap', async () => { + const many = Array.from({ length: 51 }, () => sess1); + const res = await request(app) + .post('/v1/chat') + .send({ sessionIds: many, messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('too_many_sessions'); + }); + + it('403s when a session belongs to another user', async () => { + const otherSession = await sessionsRepo.createSession({ userId: 'someone-else' }); + await sessionsRepo.saveTranscript(otherSession, { text: 't', words: [] }); + const res = await request(app) + .post('/v1/chat') + .send({ sessionIds: [sess1, otherSession], messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(403); + }); +}); + +describe('Ownership on POST /v1/sessions/:id/chat', () => { + it('403s when the session belongs to another user', async () => { + const otherSession = await sessionsRepo.createSession({ userId: 'someone-else' }); + await sessionsRepo.saveTranscript(otherSession, { text: 't', words: [] }); + const res = await request(app) + .post(`/v1/sessions/${otherSession}/chat`) + .send({ messages: [{ role: 'user', content: 'q' }] }); + expect(res.status).toBe(403); + }); +}); diff --git a/src/api/tests/unit/chatService.test.js b/src/api/tests/unit/chatService.test.js new file mode 100644 index 0000000..2958dfb --- /dev/null +++ b/src/api/tests/unit/chatService.test.js @@ -0,0 +1,187 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { chat } from '../../src/services/chatService.js'; + +const okResponse = (answer = 'The talk covered three pillars.', references = []) => ({ + content: [{ type: 'text', text: JSON.stringify({ answer, references }) }], + usage: { input_tokens: 1200, output_tokens: 180 } +}); + +describe('chat (talk scope)', () => { + it('returns assistant message with empty citations when references are empty', async () => { + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue(okResponse()) } }; + const result = await chat({ + scope: { kind: 'talk', sessionId: 'sess-1' }, + messages: [{ role: 'user', content: 'What did Sarah say?' }], + sessions: [{ + id: 'sess-1', title: 'Three pillars', speaker: 'Sarah Chen', + transcript: 'Sarah said hello.', blendedMarkdown: 'Hello.', + aiSummary: 'A talk.', photos: [] + }], + }, { anthropic: fakeAnthropic }); + expect(result.message.role).toBe('assistant'); + expect(result.message.content).toBe('The talk covered three pillars.'); + expect(result.citations).toEqual([]); + expect(result.tokensIn).toBe(1200); + expect(result.tokensOut).toBe(180); + expect(fakeAnthropic.messages.create).toHaveBeenCalledTimes(1); + }); + + it('strips [[c:N]] tokens and resolves transcript + note citations', async () => { + const okWithCites = { + content: [{ type: 'text', text: JSON.stringify({ + answer: 'Sarah opened with evals [[c:0]] and the three pillars [[c:1]].', + references: [ + { kind: 'transcript', sessionId: 'sess-1', startSec: 12.4, endSec: 24.1 }, + { kind: 'note', sessionId: 'sess-1' } + ] + }) }], + usage: { input_tokens: 1000, output_tokens: 60 } + }; + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue(okWithCites) } }; + const r = await chat({ + scope: { kind: 'talk', sessionId: 'sess-1' }, + messages: [{ role: 'user', content: 'summarize' }], + sessions: [{ id: 'sess-1', title: 'Three pillars', transcript: 't', photos: [] }], + }, { anthropic: fakeAnthropic }); + expect(r.message.content).not.toMatch(/\[\[c:/); + expect(r.citations).toHaveLength(2); + expect(r.citations[0]).toMatchObject({ kind: 'transcript', talkId: 'sess-1', label: '00:12' }); + expect(r.citations[1]).toMatchObject({ kind: 'note', noteId: 'sess-1', title: 'Three pillars' }); + }); + + it('drops references whose sessionId is not in scope', async () => { + const respWithDangling = { + content: [{ type: 'text', text: JSON.stringify({ + answer: 'See [[c:0]] and [[c:1]].', + references: [ + { kind: 'transcript', sessionId: 'sess-1', startSec: 0, endSec: 5 }, + { kind: 'transcript', sessionId: 'sess-MISSING', startSec: 10, endSec: 12 } + ] + }) }], + usage: { input_tokens: 1, output_tokens: 1 } + }; + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue(respWithDangling) } }; + const r = await chat({ + scope: { kind: 'talk', sessionId: 'sess-1' }, + messages: [{ role: 'user', content: 'q' }], + sessions: [{ id: 'sess-1', title: 'T', transcript: 't', photos: [] }], + }, { anthropic: fakeAnthropic }); + expect(r.citations).toHaveLength(1); + expect(r.citations[0].talkId).toBe('sess-1'); + }); + + it('throws on invalid JSON', async () => { + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'not json' }], + usage: { input_tokens: 1, output_tokens: 1 } + }) } }; + await expect(chat({ + scope: { kind: 'talk', sessionId: 's' }, + messages: [{ role: 'user', content: 'q' }], + sessions: [{ id: 's', transcript: '', photos: [] }] + }, { anthropic: fakeAnthropic })).rejects.toThrow(/JSON/); + }); + + it('throws on missing required field', async () => { + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ answer: 'x' }) }], + usage: { input_tokens: 1, output_tokens: 1 } + }) } }; + await expect(chat({ + scope: { kind: 'talk', sessionId: 's' }, + messages: [{ role: 'user', content: 'q' }], + sessions: [{ id: 's', transcript: '', photos: [] }] + }, { anthropic: fakeAnthropic })).rejects.toThrow(/references/); + }); +}); + +describe('chat (conference scope)', () => { + it('keeps full blends only for the 3 most recent sessions; older ones get compact summaries', async () => { + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ answer: 'ok', references: [] }) }], + usage: { input_tokens: 1, output_tokens: 1 } + }) } }; + const distinctiveBlend = 'BLENDED_BODY_MARKER'; + const sessions = Array.from({ length: 6 }, (_, i) => ({ + id: `sess-${i}`, + title: `Talk ${i}`, + transcript: `TRANSCRIPT_OF_${i}`, + blendedMarkdown: `${distinctiveBlend}_${i}`, + aiSummary: `summary ${i}`, + createdAt: new Date(2026, 0, i + 1).toISOString(), + photos: [] + })); + await chat({ + scope: { kind: 'conference', sessionIds: sessions.map(s => s.id) }, + messages: [{ role: 'user', content: 'across talks' }], + sessions + }, { anthropic: fakeAnthropic }); + const userContent = fakeAnthropic.messages.create.mock.calls[0][0].messages[0].content; + + // The 3 most recent (Talk 3, 4, 5) should include full blend body + transcript. + for (const i of [3, 4, 5]) { + expect(userContent).toContain(`${distinctiveBlend}_${i}`); + expect(userContent).toContain(`TRANSCRIPT_OF_${i}`); + } + // The 3 oldest (Talk 0, 1, 2) should appear only in compact form — no + // Transcript / Blended notes body for them. + for (const i of [0, 1, 2]) { + expect(userContent).not.toContain(`${distinctiveBlend}_${i}`); + expect(userContent).not.toContain(`TRANSCRIPT_OF_${i}`); + // Compact section header still appears for them. + expect(userContent).toContain(`Talk ${i}`); + expect(userContent).toContain(`summary ${i}`); + } + }); + + it('demotes additional sessions when the 3-recent full blends exceed the budget', async () => { + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ answer: 'ok', references: [] }) }], + usage: { input_tokens: 1, output_tokens: 1 } + }) } }; + // Three big blends — each well past 200k chars. Default rule would render + // all three; budget rule should demote until under 600k input chars. + const bigBlend = 'X'.repeat(250_000); + const sessions = [3, 4, 5].map(i => ({ + id: `sess-${i}`, + title: `Talk ${i}`, + transcript: `T_${i}`, + blendedMarkdown: bigBlend, + aiSummary: `summary ${i}`, + createdAt: new Date(2026, 0, i + 1).toISOString(), + photos: [] + })); + await chat({ + scope: { kind: 'conference', sessionIds: sessions.map(s => s.id) }, + messages: [{ role: 'user', content: 'q' }], + sessions + }, { anthropic: fakeAnthropic }); + const userContent = fakeAnthropic.messages.create.mock.calls[0][0].messages[0].content; + // Total context length must fit under 600k chars. + expect(userContent.length).toBeLessThan(600_000); + // The most recent talk (Talk 5) must still have its full blend. + expect(userContent).toContain('Talk 5'); + expect(userContent).toContain('Transcript:'); + }); +}); + +describe('citation post-processing preserves paragraphs', () => { + it('does not collapse newlines or paragraph breaks in the answer', async () => { + const multiline = { + content: [{ type: 'text', text: JSON.stringify({ + answer: 'First paragraph [[c:0]].\n\nSecond paragraph with a bullet:\n- item one\n- item two', + references: [{ kind: 'note', sessionId: 'sess-1' }] + }) }], + usage: { input_tokens: 1, output_tokens: 1 } + }; + const fakeAnthropic = { messages: { create: jest.fn().mockResolvedValue(multiline) } }; + const r = await chat({ + scope: { kind: 'talk', sessionId: 'sess-1' }, + messages: [{ role: 'user', content: 'q' }], + sessions: [{ id: 'sess-1', title: 'T', transcript: 't', photos: [] }] + }, { anthropic: fakeAnthropic }); + expect(r.message.content).toContain('\n\n'); + expect(r.message.content).toContain('- item one'); + expect(r.message.content).not.toMatch(/\[\[c:/); + }); +}); diff --git a/src/mobile/Muesli.xcodeproj/project.pbxproj b/src/mobile/Muesli.xcodeproj/project.pbxproj index b4568c8..cd11017 100644 --- a/src/mobile/Muesli.xcodeproj/project.pbxproj +++ b/src/mobile/Muesli.xcodeproj/project.pbxproj @@ -7,7 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 055F8A194C2734CBBA8871A3 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 590505C9B1C30D0ADC30CDC3 /* Foundation.framework */; }; 16E3F0DE2EB568A300E0D9B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 16E3F0DD2EB568A300E0D9B8 /* Assets.xcassets */; }; + E5A407DA9975CB54DBEC0FB9 /* MuesliRecordingLiveActivity.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 960D0CC117838C32F01ABDE6 /* MuesliRecordingLiveActivity.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + EA00F30C6565A30E50380729 /* MuesliRecordingLiveActivityBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC47B35166D92DFC5F86E2B /* MuesliRecordingLiveActivityBundle.swift */; }; + F98F9A84DD10DCFCFD6AEFE6 /* RecordingActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5757B378B63EDCD8247BB64 /* RecordingActivityAttributes.swift */; }; + FFD049B1A63F218B29755A46 /* RecordingActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF1480EFE0A734C1A451B51 /* RecordingActivityWidget.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -25,28 +30,61 @@ remoteGlobalIDString = 163E2D702E5CF13D00C16B3C; remoteInfo = Muesli; }; + 8EC50F707E8046816A27C521 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 163E2D692E5CF13D00C16B3C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3B0F36A19C3023A839BA355A; + remoteInfo = MuesliRecordingLiveActivity; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 5E354F1DDD311C9CC701C494 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + E5A407DA9975CB54DBEC0FB9 /* MuesliRecordingLiveActivity.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 163E2D712E5CF13D00C16B3C /* Muesli.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Muesli.app; sourceTree = BUILT_PRODUCTS_DIR; }; 163E2D802E5CF13E00C16B3C /* MuesliTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MuesliTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 163E2D8A2E5CF13E00C16B3C /* MuesliUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MuesliUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 16E3F0DD2EB568A300E0D9B8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2EC47B35166D92DFC5F86E2B /* MuesliRecordingLiveActivityBundle.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MuesliRecordingLiveActivityBundle.swift; sourceTree = ""; }; + 590505C9B1C30D0ADC30CDC3 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 5BF1480EFE0A734C1A451B51 /* RecordingActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RecordingActivityWidget.swift; sourceTree = ""; }; + 960D0CC117838C32F01ABDE6 /* MuesliRecordingLiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MuesliRecordingLiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + A7CEF1D7908AB7222173BD5B /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C5757B378B63EDCD8247BB64 /* RecordingActivityAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RecordingActivityAttributes.swift; path = ../Muesli/LiveActivity/RecordingActivityAttributes.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 163E2D732E5CF13D00C16B3C /* Muesli */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Muesli; sourceTree = ""; }; 163E2D832E5CF13E00C16B3C /* MuesliTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = MuesliTests; sourceTree = ""; }; 163E2D8D2E5CF13E00C16B3C /* MuesliUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = MuesliUITests; sourceTree = ""; }; @@ -74,6 +112,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F14DFC50F5BECC7D14362438 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 055F8A194C2734CBBA8871A3 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -85,6 +131,8 @@ 163E2D8D2E5CF13E00C16B3C /* MuesliUITests */, 163E2D722E5CF13D00C16B3C /* Products */, 16E3F0DD2EB568A300E0D9B8 /* Assets.xcassets */, + E2BB1181CE69B56218714ED9 /* Frameworks */, + F0A79B93B725F1BC8653351D /* MuesliRecordingLiveActivity */, ); sourceTree = ""; }; @@ -94,10 +142,39 @@ 163E2D712E5CF13D00C16B3C /* Muesli.app */, 163E2D802E5CF13E00C16B3C /* MuesliTests.xctest */, 163E2D8A2E5CF13E00C16B3C /* MuesliUITests.xctest */, + 960D0CC117838C32F01ABDE6 /* MuesliRecordingLiveActivity.appex */, ); name = Products; sourceTree = ""; }; + E2BB1181CE69B56218714ED9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + FAE4CC8947B3954C8619362B /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; + F0A79B93B725F1BC8653351D /* MuesliRecordingLiveActivity */ = { + isa = PBXGroup; + children = ( + 2EC47B35166D92DFC5F86E2B /* MuesliRecordingLiveActivityBundle.swift */, + 5BF1480EFE0A734C1A451B51 /* RecordingActivityWidget.swift */, + C5757B378B63EDCD8247BB64 /* RecordingActivityAttributes.swift */, + A7CEF1D7908AB7222173BD5B /* Info.plist */, + ); + name = MuesliRecordingLiveActivity; + path = MuesliRecordingLiveActivity; + sourceTree = ""; + }; + FAE4CC8947B3954C8619362B /* iOS */ = { + isa = PBXGroup; + children = ( + 590505C9B1C30D0ADC30CDC3 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -108,17 +185,17 @@ 163E2D6D2E5CF13D00C16B3C /* Sources */, 163E2D6E2E5CF13D00C16B3C /* Frameworks */, 163E2D6F2E5CF13D00C16B3C /* Resources */, + 5E354F1DDD311C9CC701C494 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + BD9E279F25B53A9F69A07993 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 163E2D732E5CF13D00C16B3C /* Muesli */, ); name = Muesli; - packageProductDependencies = ( - ); productName = Muesli; productReference = 163E2D712E5CF13D00C16B3C /* Muesli.app */; productType = "com.apple.product-type.application"; @@ -140,8 +217,6 @@ 163E2D832E5CF13E00C16B3C /* MuesliTests */, ); name = MuesliTests; - packageProductDependencies = ( - ); productName = MuesliTests; productReference = 163E2D802E5CF13E00C16B3C /* MuesliTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -163,12 +238,27 @@ 163E2D8D2E5CF13E00C16B3C /* MuesliUITests */, ); name = MuesliUITests; - packageProductDependencies = ( - ); productName = MuesliUITests; productReference = 163E2D8A2E5CF13E00C16B3C /* MuesliUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; + 3B0F36A19C3023A839BA355A /* MuesliRecordingLiveActivity */ = { + isa = PBXNativeTarget; + buildConfigurationList = 46369A2C5FD5A76DD0D7D28A /* Build configuration list for PBXNativeTarget "MuesliRecordingLiveActivity" */; + buildPhases = ( + 3F387D6B8E913C5E9F6090C3 /* Sources */, + F14DFC50F5BECC7D14362438 /* Frameworks */, + A11970E784D52E1CDBAC4516 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MuesliRecordingLiveActivity; + productName = MuesliRecordingLiveActivity; + productReference = 960D0CC117838C32F01ABDE6 /* MuesliRecordingLiveActivity.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -209,6 +299,7 @@ 163E2D702E5CF13D00C16B3C /* Muesli */, 163E2D7F2E5CF13E00C16B3C /* MuesliTests */, 163E2D892E5CF13E00C16B3C /* MuesliUITests */, + 3B0F36A19C3023A839BA355A /* MuesliRecordingLiveActivity */, ); }; /* End PBXProject section */ @@ -236,6 +327,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + A11970E784D52E1CDBAC4516 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -260,6 +358,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 3F387D6B8E913C5E9F6090C3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EA00F30C6565A30E50380729 /* MuesliRecordingLiveActivityBundle.swift in Sources */, + FFD049B1A63F218B29755A46 /* RecordingActivityWidget.swift in Sources */, + F98F9A84DD10DCFCFD6AEFE6 /* RecordingActivityAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -273,6 +381,12 @@ target = 163E2D702E5CF13D00C16B3C /* Muesli */; targetProxy = 163E2D8B2E5CF13E00C16B3C /* PBXContainerItemProxy */; }; + BD9E279F25B53A9F69A07993 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = MuesliRecordingLiveActivity; + target = 3B0F36A19C3023A839BA355A /* MuesliRecordingLiveActivity */; + targetProxy = 8EC50F707E8046816A27C521 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -415,8 +529,10 @@ INFOPLIST_KEY_NSMicrophoneUsageDescription = "This app requires microphone access to record voice notes for transcription."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This app needs access to your photo library to attach images to your notes."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "This app uses speech recognition to transcribe your voice notes locally on your device."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIBackgroundModes = audio; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -450,8 +566,10 @@ INFOPLIST_KEY_NSMicrophoneUsageDescription = "This app requires microphone access to record voice notes for transcription."; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "This app needs access to your photo library to attach images to your notes."; INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "This app uses speech recognition to transcribe your voice notes locally on your device."; + INFOPLIST_KEY_NSSupportsLiveActivities = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UIBackgroundModes = audio; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -537,6 +655,59 @@ }; name = Release; }; + 208F62F4F1BE609DE4DB07EB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MuesliRecordingLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.koderex.muesli.MuesliRecordingLiveActivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + DDB71F41A41AC248A38B49EA /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = MuesliRecordingLiveActivity/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.koderex.muesli.MuesliRecordingLiveActivity; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -576,6 +747,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 46369A2C5FD5A76DD0D7D28A /* Build configuration list for PBXNativeTarget "MuesliRecordingLiveActivity" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DDB71F41A41AC248A38B49EA /* Release */, + 208F62F4F1BE609DE4DB07EB /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 163E2D692E5CF13D00C16B3C /* Project object */; diff --git a/src/mobile/Muesli/AISummaryService.swift b/src/mobile/Muesli/AISummaryService.swift index 05ffa5f..ccc70d2 100644 --- a/src/mobile/Muesli/AISummaryService.swift +++ b/src/mobile/Muesli/AISummaryService.swift @@ -13,7 +13,7 @@ enum AISummaryError: Error, LocalizedError { case invalidResponse case serviceUnavailable case textTooShort - + var errorDescription: String? { switch self { case .apiEndpointNotConfigured: @@ -48,7 +48,7 @@ struct SummaryOptions: Codable { let includeActionItems: Bool let maxSummaryLength: Int let language: String - + init(includeKeyPoints: Bool = true, includeActionItems: Bool = true, maxSummaryLength: Int = 500, language: String = "en") { self.includeKeyPoints = includeKeyPoints self.includeActionItems = includeActionItems @@ -67,108 +67,105 @@ struct SummaryResponse: Codable { @Observable class AISummaryService { - static let shared = AISummaryService() - + private var urlSession: URLSession private var currentAPIBaseURL: String = "" - + // Published properties private(set) var isProcessing: Bool = false private(set) var hasValidAPIEndpoint: Bool = false - + private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 60 // Longer timeout for AI processing config.timeoutIntervalForResource = 120 self.urlSession = URLSession(configuration: config) - + Task { await loadAPIConfiguration() } } - + private func loadAPIConfiguration() async { currentAPIBaseURL = await APIConfiguration.getCurrentAPIURL() await MainActor.run { hasValidAPIEndpoint = !currentAPIBaseURL.isEmpty } } - + // MARK: - Summary Generation - + func generateSummary( text: String, sessionType: String = "note", options: SummaryOptions = SummaryOptions() ) async throws -> SummaryResult { - // Validate input guard text.trimmingCharacters(in: .whitespacesAndNewlines).count >= 50 else { throw AISummaryError.textTooShort } - + guard hasValidAPIEndpoint else { throw AISummaryError.apiEndpointNotConfigured } - + guard NetworkMonitor.shared.isConnected else { throw AISummaryError.networkError } - + await MainActor.run { isProcessing = true } - + defer { Task { @MainActor in isProcessing = false } } - + guard let summaryURL = URL(string: "\(currentAPIBaseURL)/summarize") else { throw AISummaryError.apiEndpointNotConfigured } - + var request = URLRequest(url: summaryURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let summaryRequest = SummaryRequest( text: text, type: sessionType, options: options ) - + do { let requestData = try JSONEncoder().encode(summaryRequest) request.httpBody = requestData - + AppLogger.shared.info("Sending text for AI summarization: \(text.count) characters") - + let (data, response) = try await urlSession.data(for: request) - + guard let httpResponse = response as? HTTPURLResponse else { throw AISummaryError.networkError } - + guard 200...299 ~= httpResponse.statusCode else { AppLogger.shared.warning("AI summary API returned status: \(httpResponse.statusCode)") throw AISummaryError.serviceUnavailable } - + let summaryResponse = try JSONDecoder().decode(SummaryResponse.self, from: data) - + let result = SummaryResult( summary: summaryResponse.summary, keyPoints: summaryResponse.keyPoints ?? [], actionItems: summaryResponse.actionItems ?? [], confidence: summaryResponse.confidence ?? 0.8 ) - + AppLogger.shared.info("AI summary generated successfully: \(result.summary.count) characters") return result - } catch let decodingError as DecodingError { AppLogger.shared.error("Failed to decode AI summary response", error: decodingError) throw AISummaryError.invalidResponse @@ -177,38 +174,38 @@ class AISummaryService { throw AISummaryError.networkError } } - + // MARK: - Convenience Methods - + func extractActionItems(text: String) async throws -> [String] { let options = SummaryOptions( includeKeyPoints: false, includeActionItems: true, maxSummaryLength: 200 ) - + let result = try await generateSummary(text: text, options: options) return result.actionItems } - + func generateQuickSummary(text: String, maxLength: Int = 200) async throws -> String { let options = SummaryOptions( includeKeyPoints: false, includeActionItems: false, maxSummaryLength: maxLength ) - + let result = try await generateSummary(text: text, options: options) return result.summary } - + // MARK: - Utility Methods - + func isConfigured() -> Bool { return hasValidAPIEndpoint && NetworkMonitor.shared.isConnected } - + var currentAPIEndpoint: String { return currentAPIBaseURL } -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Adapters/LiveChatAdapter.swift b/src/mobile/Muesli/Adapters/LiveChatAdapter.swift new file mode 100644 index 0000000..36afda3 --- /dev/null +++ b/src/mobile/Muesli/Adapters/LiveChatAdapter.swift @@ -0,0 +1,109 @@ +// +// LiveChatAdapter.swift +// Muesli +// +// ChatPort live adapter — talks to /v1/sessions/:id/chat (talk scope) and +// /v1/chat (multi-session conference scope). Wraps URLSession; the API +// base URL comes from a constructor parameter so dev/staging routing +// stays in one place. +// + +import Foundation + +struct LiveChatAdapter: ChatPort, @unchecked Sendable { + let baseURL: URL + let session: URLSession + + /// Resolver mapping a conference UUID to the list of backend session + /// IDs that belong to it. Tests inject a synchronous closure; the + /// production composition pre-resolves and passes via the explicit + /// variant below. + var sessionIdsResolver: (UUID) async throws -> [UUID] + + init( + baseURL: URL, + session: URLSession = .shared, + sessionIdsResolver: @escaping (UUID) async throws -> [UUID] = { _ in [] } + ) { + self.baseURL = baseURL + self.session = session + self.sessionIdsResolver = sessionIdsResolver + } + + func send(scope: ChatScope, messages: [ChatTurn]) async throws -> ChatResponse { + return try await send(scope: scope, messages: messages, sessionIdsResolver: self.sessionIdsResolver) + } + + /// Explicit-resolver variant used by tests and by ChatViewModel to bypass + /// the default closure when it already has the session list in hand. + func send( + scope: ChatScope, + messages: [ChatTurn], + sessionIdsResolver: (UUID) async throws -> [UUID] + ) async throws -> ChatResponse { + var request: URLRequest + let encoder = JSONEncoder() + + switch scope { + case .talk(let id): + request = URLRequest(url: baseURL.appendingPathComponent("/v1/sessions/\(id.uuidString)/chat")) + struct TalkBody: Encodable { let messages: [ChatTurn] } + request.httpBody = try encoder.encode(TalkBody(messages: messages)) + case .conference(let id): + let sessionIds = try await sessionIdsResolver(id) + request = URLRequest(url: baseURL.appendingPathComponent("/v1/chat")) + struct ConfBody: Encodable { let sessionIds: [UUID]; let messages: [ChatTurn] } + request.httpBody = try encoder.encode(ConfBody(sessionIds: sessionIds, messages: messages)) + } + + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let (data, response) = try await Self.dataWithRefresh(session: session, request: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw ChatAdapterError.http(statusCode: (response as? HTTPURLResponse)?.statusCode ?? -1, body: data) + } + struct Envelope: Decodable { + struct Usage: Decodable { let tokensIn: Int; let tokensOut: Int } + let message: ChatTurn + let citations: [ChatCitation] + let usage: Usage + } + let env = try JSONDecoder().decode(Envelope.self, from: data) + return ChatResponse(message: env.message, citations: env.citations) + } +} + +extension LiveChatAdapter { + /// Authorize + send. On a 401, refresh the access token once and retry. + fileprivate static func dataWithRefresh(session: URLSession, request: URLRequest) async throws -> (Data, URLResponse) { + var first = request + if let token = await TokenStore.shared.accessToken, !token.isEmpty { + first.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (data, response) = try await session.data(for: first) + guard let http = response as? HTTPURLResponse, http.statusCode == 401 else { + return (data, response) + } + do { + _ = try await AuthService.shared.refreshAccessToken() + } catch { + return (data, response) + } + var retry = request + if let token = await TokenStore.shared.accessToken, !token.isEmpty { + retry.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + return try await session.data(for: retry) + } +} + +enum ChatAdapterError: Error, LocalizedError { + case http(statusCode: Int, body: Data) + + var errorDescription: String? { + switch self { + case .http(let code, _): return "Chat request failed (HTTP \(code))." + } + } +} diff --git a/src/mobile/Muesli/AudioRecordingManager.swift b/src/mobile/Muesli/AudioRecordingManager.swift index e8dee5a..3e32df8 100644 --- a/src/mobile/Muesli/AudioRecordingManager.swift +++ b/src/mobile/Muesli/AudioRecordingManager.swift @@ -21,7 +21,7 @@ enum RecordingError: Error, LocalizedError { case recordingFailed case fileNotFound case audioSessionError - + var errorDescription: String? { switch self { case .permissionDenied: @@ -38,12 +38,11 @@ enum RecordingError: Error, LocalizedError { @Observable class AudioRecordingManager: NSObject { - static let shared = AudioRecordingManager() - + private var audioRecorder: AVAudioRecorder? - private var audioSession: AVAudioSession = AVAudioSession.sharedInstance() - + private var audioSession = AVAudioSession.sharedInstance() + // Published properties private(set) var state: RecordingState = .idle private(set) var currentRecordingPath: String? @@ -52,18 +51,18 @@ class AudioRecordingManager: NSObject { private(set) var audioLevel: Float = 0.0 private(set) var averagePower: Float = 0.0 private(set) var peakPower: Float = 0.0 - + // Timer for updating duration and audio levels private var durationTimer: Timer? private var recordingStartTime: Date? - - private override init() { + + override private init() { super.init() setupAudioSession() } - + // MARK: - Permission Management - + func requestPermission() async -> Bool { await withCheckedContinuation { continuation in AVAudioApplication.requestRecordPermission { granted in @@ -74,7 +73,7 @@ class AudioRecordingManager: NSObject { } } } - + func checkPermission() { switch AVAudioApplication.shared.recordPermission { case .granted: @@ -85,61 +84,61 @@ class AudioRecordingManager: NSObject { hasPermission = false } } - + // MARK: - Recording Controls - + func startRecording(fileName: String? = nil) async throws -> String { guard hasPermission else { throw RecordingError.permissionDenied } - + // Stop any existing recording first if audioRecorder?.isRecording == true { AppLogger.shared.info("Stopping existing recording before starting new one") audioRecorder?.stop() } - + // Generate file path let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let audioFilename = fileName ?? "recording_\(UUID().uuidString).wav" let audioURL = documentsPath.appendingPathComponent(audioFilename) - + // Audio recording settings - using more compatible format for iOS Simulator let settings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatLinearPCM), // Use uncompressed PCM for better compatibility - AVSampleRateKey: 44100.0, // Standard sample rate + AVSampleRateKey: 44_100.0, // Standard sample rate AVNumberOfChannelsKey: 1, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false ] - + do { // Use simpler, more compatible audio session configuration try audioSession.setCategory(.record, mode: .default) try audioSession.setActive(true) - + // Add a small delay to let audio session stabilize try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds - + audioRecorder = try AVAudioRecorder(url: audioURL, settings: settings) audioRecorder?.delegate = self audioRecorder?.isMeteringEnabled = true - + // Ensure prepareToRecord succeeds guard let recorder = audioRecorder else { AppLogger.shared.error("Failed to create AVAudioRecorder") throw RecordingError.recordingFailed } - + guard recorder.prepareToRecord() else { AppLogger.shared.error("Failed to prepare recorder - URL: \(audioURL), Settings: \(settings)") throw RecordingError.recordingFailed } - + // Add a small delay before starting recording try await Task.sleep(nanoseconds: 50_000_000) // 0.05 seconds - + let success = audioRecorder?.record() ?? false if success { recordingStartTime = Date() @@ -148,11 +147,25 @@ class AudioRecordingManager: NSObject { self.currentRecordingPath = audioFilename self.recordingDuration = 0 } - + // Verify recording actually started try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds if audioRecorder?.isRecording == true { startDurationTimer() + // Kick off the Live Activity (Dynamic Island banner). + // No-op when the widget extension target isn't installed + // or Live Activities are user-disabled. + // TODO: thread Note.backendSessionId through once the + // recording flow knows the eventual session ID (it's + // currently assigned later by BlendOrchestrator). + if #available(iOS 16.2, *) { + await MainActor.run { + LiveActivityController.shared.start( + title: "Recording", + sessionId: UUID() + ) + } + } AppLogger.shared.info("Started recording: \(audioFilename) - Verified recording is active") return audioFilename } else { @@ -168,10 +181,10 @@ class AudioRecordingManager: NSObject { throw RecordingError.recordingFailed } } - + func pauseRecording() { guard state == .recording else { return } - + audioRecorder?.pause() DispatchQueue.main.async { self.state = .paused @@ -179,17 +192,17 @@ class AudioRecordingManager: NSObject { // Keep timer running to track duration even when paused AppLogger.shared.info("Paused recording") } - + func resumeRecording() { guard state == .paused else { return } - + // Ensure audio session is still active do { try audioSession.setActive(true) } catch { AppLogger.shared.warning("Failed to reactivate audio session on resume: \(error)") } - + audioRecorder?.record() DispatchQueue.main.async { self.state = .recording @@ -197,60 +210,67 @@ class AudioRecordingManager: NSObject { // Timer should already be running AppLogger.shared.info("Resumed recording") } - + func stopRecording() { - guard state == .recording || state == .paused else { + guard state == .recording || state == .paused else { AppLogger.shared.warning("stopRecording called but state is: \(state)") - return + return } - + AppLogger.shared.info("stopRecording called - current duration: \(recordingDuration)s, state: \(state)") - + audioRecorder?.stop() DispatchQueue.main.async { self.state = .finished } stopDurationTimer() - + do { try audioSession.setActive(false) } catch { AppLogger.shared.warning("Failed to deactivate audio session: \(error)") } - + + // Tear down the Live Activity banner. + if #available(iOS 16.2, *) { + Task { @MainActor in + await LiveActivityController.shared.end() + } + } + AppLogger.shared.info("Stopped recording. Duration: \(recordingDuration)s") } - + func cancelRecording() { guard state == .recording || state == .paused else { return } - + audioRecorder?.stop() state = .idle stopDurationTimer() - + // Delete the recording file if let path = currentRecordingPath { deleteRecording(fileName: path) } - + currentRecordingPath = nil recordingDuration = 0 - + do { try audioSession.setActive(false) } catch { AppLogger.shared.warning("Failed to deactivate audio session: \(error)") } - + AppLogger.shared.info("Cancelled recording") } - + // MARK: - File Management - + func deleteRecording(fileName: String) { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let audioURL = documentsPath.appendingPathComponent(fileName) - + do { try FileManager.default.removeItem(at: audioURL) AppLogger.shared.info("Deleted recording: \(fileName)") @@ -258,19 +278,19 @@ class AudioRecordingManager: NSObject { AppLogger.shared.error("Failed to delete recording: \(fileName)", error: error) } } - + func getRecordingURL(fileName: String) -> URL? { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let audioURL = documentsPath.appendingPathComponent(fileName) - + if FileManager.default.fileExists(atPath: audioURL.path) { return audioURL } return nil } - + // MARK: - Private Methods - + private func setupAudioSession() { do { try audioSession.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP]) @@ -279,52 +299,64 @@ class AudioRecordingManager: NSObject { AppLogger.shared.error("Failed to setup audio session", error: error) } } - + private func startDurationTimer() { stopDurationTimer() AppLogger.shared.info("Starting duration timer with 0.1s interval") - + // Ensure timer is created on main thread and added to main run loop DispatchQueue.main.async { self.durationTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in - guard let self = self else { + guard let self = self else { AppLogger.shared.warning("Timer callback: self is nil") timer.invalidate() - return + return } - + // Debug: Log first few timer fires if self.recordingDuration < 1.0 { AppLogger.shared.info("Timer fired - duration: \(self.recordingDuration), state: \(self.state)") } - - guard let recorder = self.audioRecorder else { + + guard let recorder = self.audioRecorder else { AppLogger.shared.warning("Timer callback: recorder is nil") - return + return } - + // Check if recorder is actually recording guard recorder.isRecording else { AppLogger.shared.warning("Timer callback: recorder.isRecording is false - stopping timer updates") return } - + // Log every 10th callback (every 1 second) and first few callbacks let currentTime = recorder.currentTime let callbackCount = Int(currentTime * 10) if callbackCount % 10 == 0 || callbackCount < 10 { AppLogger.shared.info("Timer callback #\(callbackCount): currentTime=\(currentTime), state=\(self.state), isRecording=\(recorder.isRecording)") } - + // Update duration and audio levels (already on main thread) self.recordingDuration = recorder.currentTime - + + // Push elapsed time to the Live Activity once a second. + if #available(iOS 16.2, *), Int(self.recordingDuration * 10) % 10 == 0 { + let elapsed = Int(self.recordingDuration) + let paused = (self.state == .paused) + Task { @MainActor in + await LiveActivityController.shared.update( + elapsedSeconds: elapsed, + isPaused: paused + ) + } + } + // Only update audio levels when actively recording if self.state == .recording && recorder.isRecording { recorder.updateMeters() self.averagePower = recorder.averagePower(forChannel: 0) self.peakPower = recorder.peakPower(forChannel: 0) - + // Normalize audio level (0.0 to 1.0) // Average power ranges from -160 dB (silence) to 0 dB (max) // Map -50 dB to 0.0 and 0 dB to 1.0 for better visual range @@ -333,7 +365,7 @@ class AudioRecordingManager: NSObject { let clampedPower = max(minDB, min(maxDB, self.averagePower)) let normalizedLevel = (clampedPower - minDB) / (maxDB - minDB) self.audioLevel = normalizedLevel - + // Debug audio levels for first few seconds if self.recordingDuration < 3.0 && callbackCount % 5 == 0 { AppLogger.shared.info("Audio levels - avgPower: \(self.averagePower), peakPower: \(self.peakPower), normalizedLevel: \(normalizedLevel), audioLevel: \(self.audioLevel)") @@ -345,7 +377,7 @@ class AudioRecordingManager: NSObject { self.peakPower = 0.0 } } - + // Add timer to current run loop to ensure it fires if let timer = self.durationTimer { RunLoop.current.add(timer, forMode: .common) @@ -355,11 +387,11 @@ class AudioRecordingManager: NSObject { } } } - + private func stopDurationTimer() { durationTimer?.invalidate() durationTimer = nil - + // Reset audio levels when not recording audioLevel = 0.0 averagePower = 0.0 @@ -370,7 +402,6 @@ class AudioRecordingManager: NSObject { // MARK: - AVAudioRecorderDelegate extension AudioRecordingManager: AVAudioRecorderDelegate { - nonisolated func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { if recorder.currentTime < 1.0 { AppLogger.shared.warning("Recording finished too quickly - possible audio session conflict or simulator limitation") @@ -407,4 +438,3 @@ extension AudioRecordingManager: AVAudioRecorderDelegate { } } } - diff --git a/src/mobile/Muesli/Config/APIConfiguration.swift b/src/mobile/Muesli/Config/APIConfiguration.swift index 1e448df..7254b0c 100644 --- a/src/mobile/Muesli/Config/APIConfiguration.swift +++ b/src/mobile/Muesli/Config/APIConfiguration.swift @@ -11,22 +11,21 @@ import Foundation typealias APIConfig = APIConfiguration struct APIConfiguration { - // MARK: - Build-time Configuration - + static let transcriptionAPIBaseURL: String = { #if DEBUG // Development: Check localhost first, fallback to staging return "http://localhost:3000/api/v1" #elseif STAGING // Staging environment - return "https://staging-api.muesli-app.com/api/v1" + return "https://staging-api.muesli-app.com/api/v1" #else // Production environment return "https://api.muesli-app.com/api/v1" #endif }() - + static let fallbackAPIBaseURL: String = { #if DEBUG // If localhost fails in debug, fallback to staging @@ -36,19 +35,19 @@ struct APIConfiguration { return "https://api-backup.muesli-app.com/api/v1" #endif }() - + // MARK: - Environment Info - + static let environmentName: String = { #if DEBUG return "Development" - #elseif STAGING + #elseif STAGING return "Staging" #else return "Production" #endif }() - + static let isDevelopment: Bool = { #if DEBUG return true @@ -56,17 +55,17 @@ struct APIConfiguration { return false #endif }() - + // MARK: - Localhost Detection - + static func checkLocalhostAvailability() async -> Bool { guard isDevelopment else { return false } guard let url = URL(string: "http://localhost:3000/health") else { return false } - + do { let request = URLRequest(url: url, timeoutInterval: 2.0) let (_, response) = try await URLSession.shared.data(for: request) - + if let httpResponse = response as? HTTPURLResponse { AppLogger.shared.debug("Localhost health check: \(httpResponse.statusCode)") return httpResponse.statusCode == 200 @@ -74,10 +73,10 @@ struct APIConfiguration { } catch { AppLogger.shared.debug("Localhost unavailable: \(error.localizedDescription)") } - + return false } - + // MARK: - Typed Base URL (for SessionsService and other URL-typed consumers) /// Base URL without the `/api/v1` path suffix — used by SessionsService which appends `/v1/...` itself. @@ -103,9 +102,9 @@ struct APIConfiguration { return fallbackAPIBaseURL } } - + // For staging/production, always use primary URL AppLogger.shared.info("Using \(environmentName) API: \(transcriptionAPIBaseURL)") return transcriptionAPIBaseURL } -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Constants/AppConstants.swift b/src/mobile/Muesli/Constants/AppConstants.swift index 84d642a..0b4b6af 100644 --- a/src/mobile/Muesli/Constants/AppConstants.swift +++ b/src/mobile/Muesli/Constants/AppConstants.swift @@ -9,7 +9,6 @@ import Foundation import AVFoundation struct AppConstants { - // MARK: - Timing Constants struct Timing { static let recordingTimerInterval: TimeInterval = 0.1 @@ -17,17 +16,17 @@ struct AppConstants { static let healthCheckTimeout: TimeInterval = 2.0 static let transcriptionProcessTimeout: TimeInterval = 300.0 } - + // MARK: - Audio Constants struct Audio { - static let defaultSampleRate: Double = 44100.0 - static let bitRate: Int = 64000 + static let defaultSampleRate: Double = 44_100.0 + static let bitRate: Int = 64_000 static let numberOfChannels: Int = 1 static let audioQuality: AVAudioQuality = .high static let fileExtension: String = "m4a" static let contentType: String = "audio/mp4" } - + // MARK: - UI Constants struct UI { static let defaultPadding: CGFloat = 16 @@ -37,14 +36,14 @@ struct AppConstants { static let buttonHeight: CGFloat = 44 static let recordingButtonSize: CGFloat = 100 } - + // MARK: - Performance Constants struct Performance { static let maxCachedOperations: Int = 100 static let logRetentionDays: Int = 7 - static let maxFileSize: Int64 = 50 * 1024 * 1024 // 50MB + static let maxFileSize: Int64 = 50 * 1_024 * 1_024 // 50MB } - + // MARK: - Transcription Configuration struct Transcription { // Duration threshold for switching from local to cloud (in seconds) @@ -52,14 +51,14 @@ struct AppConstants { // Local transcription limits (iOS Speech framework) static let localDailyLimit: TimeInterval = 60 * 60 // ~1 hour per day (Apple limit) - static let localMaxFileSize: Int64 = 10 * 1024 * 1024 // 10MB + static let localMaxFileSize: Int64 = 10 * 1_024 * 1_024 // 10MB // Cloud transcription settings - static let cloudMinFileSize: Int64 = 1024 // 1KB - static let cloudMaxFileSize: Int64 = 50 * 1024 * 1024 // 50MB + static let cloudMinFileSize: Int64 = 1_024 // 1KB + static let cloudMaxFileSize: Int64 = 50 * 1_024 * 1_024 // 50MB // Real-time transcription - static let realtimeBufferSize: Int = 4096 + static let realtimeBufferSize: Int = 4_096 static let realtimeUpdateInterval: TimeInterval = 0.5 } @@ -81,7 +80,7 @@ struct AppConstants { } } } - + // MARK: - Session Types enum SessionType: String, CaseIterable { case note = "note" @@ -90,7 +89,7 @@ struct AppConstants { case voiceNote = "voice-note" case interview = "interview" case lecture = "lecture" - + var displayName: String { switch self { case .note: return "Note" @@ -101,7 +100,7 @@ struct AppConstants { case .lecture: return "Lecture" } } - + var icon: String { switch self { case .note: return "note.text" @@ -113,21 +112,21 @@ struct AppConstants { } } } - + // MARK: - File Paths struct FilePaths { static let documentsDirectory = "Documents" static let audioDirectory = "Audio" static let logsDirectory = "Logs" } - + // MARK: - Validation struct Validation { static let minTitleLength: Int = 1 static let maxTitleLength: Int = 100 - static let maxContentLength: Int = 50000 + static let maxContentLength: Int = 50_000 static let minRecordingDuration: TimeInterval = 1.0 - static let maxRecordingDuration: TimeInterval = 7200.0 // 2 hours + static let maxRecordingDuration: TimeInterval = 7_200.0 // 2 hours } } @@ -150,4 +149,4 @@ extension AppConstants { static let transcriptionCompleted = Notification.Name("transcriptionCompleted") static let networkStatusChanged = Notification.Name("networkStatusChanged") } -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/ContentUtilities.swift b/src/mobile/Muesli/ContentUtilities.swift index b0805fc..540181c 100644 --- a/src/mobile/Muesli/ContentUtilities.swift +++ b/src/mobile/Muesli/ContentUtilities.swift @@ -12,16 +12,15 @@ enum ContentType { } struct ContentUtilities { - // MARK: - Content Parsing - + static func parseContent(_ content: String) -> [(String, ContentType)] { let lines = content.components(separatedBy: .newlines) var result: [(String, ContentType)] = [] - + for line in lines { let trimmed = line.trimmingCharacters(in: .whitespaces) - + if trimmed.isEmpty { continue } else if trimmed.hasPrefix("# ") { @@ -37,64 +36,64 @@ struct ContentUtilities { result.append((trimmed, .bullet)) } } - + return result } - + // MARK: - Personal Notes Extraction - + static func extractPersonalNotes(from content: String) -> [String] { let lines = content.components(separatedBy: .newlines) return lines.filter { line in let trimmed = line.trimmingCharacters(in: .whitespaces) return trimmed.contains("[Personal]") || - trimmed.contains("[Action]") || - trimmed.contains("Action items") || - trimmed.contains("Follow up") || - trimmed.contains("Goals for") || - trimmed.contains("Next steps") || - trimmed.contains("Personal") || - trimmed.hasPrefix("• Schedule") || - trimmed.hasPrefix("• Complete") || - trimmed.hasPrefix("• Implement") || - trimmed.hasPrefix("• Share") || - trimmed.hasPrefix("○ Schedule") || - trimmed.hasPrefix("○ Get quotes") || - trimmed.hasPrefix("○ Send notice") + trimmed.contains("[Action]") || + trimmed.contains("Action items") || + trimmed.contains("Follow up") || + trimmed.contains("Goals for") || + trimmed.contains("Next steps") || + trimmed.contains("Personal") || + trimmed.hasPrefix("• Schedule") || + trimmed.hasPrefix("• Complete") || + trimmed.hasPrefix("• Implement") || + trimmed.hasPrefix("• Share") || + trimmed.hasPrefix("○ Schedule") || + trimmed.hasPrefix("○ Get quotes") || + trimmed.hasPrefix("○ Send notice") }.map { $0.trimmingCharacters(in: .whitespaces) } } - + // MARK: - Sample Content for Transcripts - + static let sampleTranscript = """ [00:00] Welcome everyone to today's meeting. Let's get started with our agenda. - + [00:15] First item is the project status update. Sarah, could you walk us through the numbers? - + [00:30] Sarah: Absolutely. We've made significant progress this quarter. Our key milestones have been achieved on schedule. - + [01:15] The outstanding tasks are manageable, and we're on track for our delivery timeline. - + [01:45] One item that needs attention is the resource allocation for the next phase. - + [02:00] Team Lead: What about our budget considerations for Q4? - + [02:10] Sarah: We're within budget limits. Current spending is tracking at 85% of allocated funds. - + [02:45] This gives us flexibility for any unexpected requirements or opportunities. - + [03:15] We'll discuss budget adjustments in next week's planning session. - + [03:30] Any other financial considerations we should address today? - + [03:40] Team Lead: The equipment procurement is still pending approval. - + [04:00] Sarah: Moving on to our action items for next week. We need to finalize vendor contracts and schedule team reviews. - + [04:30] Let's also prepare for the client presentation scheduled for next month. - + [05:00] Action items: finalize contracts, schedule reviews, prepare presentation materials. - + [05:30] Meeting adjourned. Thank you everyone for your participation. """ } diff --git a/src/mobile/Muesli/DataService.swift b/src/mobile/Muesli/DataService.swift index bc5a8eb..a7a0606 100644 --- a/src/mobile/Muesli/DataService.swift +++ b/src/mobile/Muesli/DataService.swift @@ -24,13 +24,13 @@ extension EnvironmentValues { @Observable class DataService { private var modelContext: ModelContext - + init(modelContext: ModelContext) { self.modelContext = modelContext } - + // MARK: - Note Operations - + func createNote( title: String, content: String = "", @@ -46,13 +46,13 @@ class DataService { sessionType: sessionType, isArchived: false ) - + modelContext.insert(note) try modelContext.save() AppLogger.shared.dataSuccess("Create Note", details: "Title: \(title)") } } - + func updateNote(_ note: Note, title: String? = nil, content: String? = nil) throws { try PerformanceMonitor.shared.measure(operation: "Update Note") { if let title = title { @@ -65,31 +65,31 @@ class DataService { AppLogger.shared.dataSuccess("Update Note", details: "Title: \(note.title)") } } - + func archiveNote(_ note: Note) throws { note.isArchived = true try modelContext.save() } - + func unarchiveNote(_ note: Note) throws { note.isArchived = false try modelContext.save() } - + func deleteNote(_ note: Note) throws { modelContext.delete(note) try modelContext.save() } - + // MARK: - Query Operations - + func fetchActiveNotes() -> [Note] { return PerformanceMonitor.shared.measure(operation: "Fetch Active Notes") { let descriptor = FetchDescriptor( predicate: #Predicate { !$0.isArchived }, sortBy: [SortDescriptor(\.timestamp, order: .reverse)] ) - + do { let notes = try modelContext.fetch(descriptor) AppLogger.shared.dataSuccess("Fetch Active Notes", details: "Count: \(notes.count)") @@ -100,13 +100,13 @@ class DataService { } } } - + func fetchArchivedNotes() -> [Note] { let descriptor = FetchDescriptor( predicate: #Predicate { $0.isArchived }, sortBy: [SortDescriptor(\.timestamp, order: .reverse)] ) - + do { let notes = try modelContext.fetch(descriptor) AppLogger.shared.dataSuccess("Fetch Archived Notes", details: "Count: \(notes.count)") @@ -116,25 +116,25 @@ class DataService { return [] } } - + func searchNotes(query: String, includeArchived: Bool = false) -> [Note] { return PerformanceMonitor.shared.measure(operation: "Search Notes") { let searchQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) - + guard !searchQuery.isEmpty else { return includeArchived ? fetchAllNotes() : fetchActiveNotes() } - + let descriptor = FetchDescriptor( predicate: #Predicate { note in - (note.title.localizedStandardContains(searchQuery) || - note.content.localizedStandardContains(searchQuery) || - (note.conferenceName?.localizedStandardContains(searchQuery) ?? false)) && - (includeArchived || !note.isArchived) + (note.title.localizedStandardContains(searchQuery) || + note.content.localizedStandardContains(searchQuery) || + (note.conferenceName?.localizedStandardContains(searchQuery) ?? false)) && + (includeArchived || !note.isArchived) }, sortBy: [SortDescriptor(\.timestamp, order: .reverse)] ) - + do { let results = try modelContext.fetch(descriptor) AppLogger.shared.searchOperation(query: searchQuery, resultCount: results.count, includeArchived: includeArchived) @@ -145,12 +145,12 @@ class DataService { } } } - + func fetchAllNotes() -> [Note] { let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.timestamp, order: .reverse)] ) - + do { let notes = try modelContext.fetch(descriptor) AppLogger.shared.dataSuccess("Fetch All Notes", details: "Count: \(notes.count)") @@ -160,45 +160,45 @@ class DataService { return [] } } - + // MARK: - Statistics - + func getArchivedCount() -> Int { fetchArchivedNotes().count } - + func getTotalNotesCount() -> Int { fetchAllNotes().count } - + // MARK: - Sample Data Seeding - + func seedSampleDataIfNeeded() throws { let existingNotes = fetchAllNotes() - + // Only seed if there are no existing notes guard existingNotes.isEmpty else { return } - + let sampleNotes = [ Note( title: "Welcome to Muesli", content: """ # Getting Started - + • Create new notes by tapping the "New" button • Organize notes by type: note, meeting, or session • Archive notes you no longer need • Search through all your notes instantly - + # Features - + ○ Real-time sync across devices ○ Markdown-style formatting support ○ Archive and search functionality ○ Conference and meeting organization - + # Next Steps - + • Explore the app interface • Create your first note • Try the search functionality @@ -210,20 +210,20 @@ class DataService { title: "Sample Meeting Notes", content: """ # Project Kickoff Meeting - + • Discussed project timeline and milestones • Assigned roles and responsibilities • Reviewed budget and resource allocation - + # Action Items - + ○ Schedule weekly check-ins ○ Set up project repository ○ Create initial documentation ○ Send meeting summary to stakeholders - + # Next Meeting - + • Date: Next Friday at 2:00 PM • Focus: Technical architecture review • Attendees: Full development team @@ -233,11 +233,11 @@ class DataService { sessionType: "meeting" ) ] - + for note in sampleNotes { modelContext.insert(note) } - + try modelContext.save() } } diff --git a/src/mobile/Muesli/HybridTranscriptionService.swift b/src/mobile/Muesli/HybridTranscriptionService.swift index e168581..7fb9bee 100644 --- a/src/mobile/Muesli/HybridTranscriptionService.swift +++ b/src/mobile/Muesli/HybridTranscriptionService.swift @@ -42,8 +42,7 @@ enum HybridTranscriptionError: Error, LocalizedError { } @Observable -class HybridTranscriptionService { - +class HybridTranscriptionService: HybridTranscriptionPort { static let shared = HybridTranscriptionService() // Service instances diff --git a/src/mobile/Muesli/LiveActivity/LiveActivityController.swift b/src/mobile/Muesli/LiveActivity/LiveActivityController.swift new file mode 100644 index 0000000..6c00e57 --- /dev/null +++ b/src/mobile/Muesli/LiveActivity/LiveActivityController.swift @@ -0,0 +1,54 @@ +// +// LiveActivityController.swift +// Muesli +// +// Thin wrapper around ActivityKit for the recording Live Activity. +// Gracefully degrades to a no-op when: +// - Running on iOS < 16.2 +// - The widget extension target hasn't been added yet +// - The user has disabled Live Activities in Settings +// + +import Foundation +import ActivityKit + +@MainActor +final class LiveActivityController { + static let shared = LiveActivityController() + private init() {} + + private var current: Activity? + + func start(title: String, sessionId: UUID) { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + AppLogger.shared.info("LiveActivity: not enabled (no widget extension or user-disabled); skipping") + return + } + let attributes = RecordingActivityAttributes( + title: title, + sessionId: sessionId, + startedAt: Date() + ) + let initialState = RecordingActivityAttributes.ContentState(elapsedSeconds: 0, isPaused: false) + do { + current = try Activity.request( + attributes: attributes, + content: .init(state: initialState, staleDate: nil) + ) + } catch { + AppLogger.shared.warning("LiveActivity: start failed — \(error.localizedDescription)") + } + } + + func update(elapsedSeconds: Int, isPaused: Bool) async { + guard let current else { return } + let state = RecordingActivityAttributes.ContentState(elapsedSeconds: elapsedSeconds, isPaused: isPaused) + await current.update(.init(state: state, staleDate: nil)) + } + + func end() async { + guard let current else { return } + await current.end(dismissalPolicy: .immediate) + self.current = nil + } +} diff --git a/src/mobile/Muesli/LiveActivity/RecordingActivityAttributes.swift b/src/mobile/Muesli/LiveActivity/RecordingActivityAttributes.swift new file mode 100644 index 0000000..d1aacbc --- /dev/null +++ b/src/mobile/Muesli/LiveActivity/RecordingActivityAttributes.swift @@ -0,0 +1,40 @@ +// +// RecordingActivityAttributes.swift +// Muesli +// +// Shared ActivityKit attributes for the recording Live Activity. +// This type is also referenced by the Widget Extension target whose +// UI renders the actual Dynamic Island / lock-screen content. +// +// *** Widget Extension target setup (manual, one-time) *** +// +// Live Activities REQUIRE a Widget Extension target. To enable the +// Dynamic Island banner: +// +// 1. In Xcode: File → New → Target → Widget Extension. Name it +// `MuesliRecordingLiveActivity`, embed in the Muesli app target. +// 2. In the extension target, add this file via "Add Files to Target" +// so the ActivityAttributes type is shared. +// 3. Replace the stock widget body with an `ActivityConfiguration` for +// `RecordingActivityAttributes` rendering elapsed time + title. +// 4. Ensure Muesli's Info.plist has UIBackgroundModes including +// "audio" so the recording survives backgrounding. +// +// Until step 1 ships, `LiveActivityController.start()` no-ops because +// `ActivityAuthorizationInfo().areActivitiesEnabled` returns false +// with no hosting extension. +// + +import Foundation +import ActivityKit + +struct RecordingActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var elapsedSeconds: Int + var isPaused: Bool + } + + var title: String + var sessionId: UUID + var startedAt: Date +} diff --git a/src/mobile/Muesli/LocalTranscriptionService.swift b/src/mobile/Muesli/LocalTranscriptionService.swift index f0b2b29..ab79f38 100644 --- a/src/mobile/Muesli/LocalTranscriptionService.swift +++ b/src/mobile/Muesli/LocalTranscriptionService.swift @@ -15,7 +15,7 @@ enum LocalTranscriptionError: Error, LocalizedError { case audioFileNotFound case recognitionFailed case unsupportedLanguage - + var errorDescription: String? { switch self { case .speechRecognitionNotAvailable: @@ -34,55 +34,54 @@ enum LocalTranscriptionError: Error, LocalizedError { @Observable class LocalTranscriptionService: NSObject { - static let shared = LocalTranscriptionService() - + private let speechRecognizer: SFSpeechRecognizer? private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest? private var recognitionTask: SFSpeechRecognitionTask? private let audioEngine = AVAudioEngine() - + // Published properties private(set) var isTranscribing: Bool = false private(set) var hasPermission: Bool = false private(set) var isAvailable: Bool = false private(set) var currentTranscript: String = "" - + // Callbacks var onTranscriptionUpdate: ((String, Bool) -> Void)? var onError: ((Error) -> Void)? - - private override init() { + + override private init() { // Initialize with device locale, fallback to English speechRecognizer = SFSpeechRecognizer(locale: Locale.current) ?? SFSpeechRecognizer(locale: Locale(identifier: "en-US")) isAvailable = speechRecognizer?.isAvailable ?? false super.init() - + // Monitor availability changes speechRecognizer?.delegate = self - + checkPermissions() } - + // MARK: - Permission Management - + func requestPermissions() async -> Bool { // Request speech recognition permission let speechPermission = await requestSpeechPermission() - + // Request microphone permission (if not already granted) let micPermission = await AudioRecordingManager.shared.requestPermission() - + let granted = speechPermission && micPermission await MainActor.run { hasPermission = granted } - + AppLogger.shared.info("Local transcription permissions - Speech: \(speechPermission), Microphone: \(micPermission)") return granted } - + private func requestSpeechPermission() async -> Bool { await withCheckedContinuation { continuation in SFSpeechRecognizer.requestAuthorization { status in @@ -91,47 +90,47 @@ class LocalTranscriptionService: NSObject { } } } - + private func checkPermissions() { let speechStatus = SFSpeechRecognizer.authorizationStatus() let micStatus = AVAudioApplication.shared.recordPermission hasPermission = speechStatus == .authorized && micStatus == .granted } - + // MARK: - Real-time Transcription - + func startRealtimeTranscription() async throws { guard isAvailable else { throw LocalTranscriptionError.speechRecognitionNotAvailable } - + guard hasPermission else { throw LocalTranscriptionError.permissionDenied } - + // Stop any existing transcription if isTranscribing { stopRealtimeTranscription() } - + try await setupAudioSession() - + recognitionRequest = SFSpeechAudioBufferRecognitionRequest() guard let recognitionRequest = recognitionRequest else { throw LocalTranscriptionError.recognitionFailed } - + // Configure recognition request recognitionRequest.shouldReportPartialResults = true if #available(iOS 16.0, *) { recognitionRequest.addsPunctuation = true } - + // Start recognition task recognitionTask = speechRecognizer?.recognitionTask(with: recognitionRequest) { [weak self] result, error in guard let self = self else { return } - + if let error = error { AppLogger.shared.error("Speech recognition error", error: error) DispatchQueue.main.async { @@ -140,85 +139,85 @@ class LocalTranscriptionService: NSObject { } return } - + if let result = result { let transcript = result.bestTranscription.formattedString let isFinal = result.isFinal - + DispatchQueue.main.async { self.currentTranscript = transcript self.onTranscriptionUpdate?(transcript, isFinal) } - + if isFinal { AppLogger.shared.info("Final transcription result: \(transcript.prefix(50))...") } } } - + // Start audio engine let inputNode = audioEngine.inputNode let recordingFormat = inputNode.outputFormat(forBus: 0) - - inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, _ in + + inputNode.installTap(onBus: 0, bufferSize: 1_024, format: recordingFormat) { buffer, _ in recognitionRequest.append(buffer) } - + audioEngine.prepare() try audioEngine.start() - + await MainActor.run { isTranscribing = true currentTranscript = "" } - + AppLogger.shared.info("Started local real-time transcription") } - + func stopRealtimeTranscription() { audioEngine.stop() audioEngine.inputNode.removeTap(onBus: 0) - + recognitionRequest?.endAudio() recognitionRequest = nil - + recognitionTask?.cancel() recognitionTask = nil - + isTranscribing = false - + AppLogger.shared.info("Stopped local real-time transcription") } - + // MARK: - File Transcription - + func transcribeAudioFile(url: URL) async throws -> String { guard isAvailable else { throw LocalTranscriptionError.speechRecognitionNotAvailable } - + guard hasPermission else { throw LocalTranscriptionError.permissionDenied } - + guard FileManager.default.fileExists(atPath: url.path) else { throw LocalTranscriptionError.audioFileNotFound } - + return try await withCheckedThrowingContinuation { continuation in let request = SFSpeechURLRecognitionRequest(url: url) request.shouldReportPartialResults = false if #available(iOS 16.0, *) { request.addsPunctuation = true } - + speechRecognizer?.recognitionTask(with: request) { result, error in if let error = error { AppLogger.shared.error("File transcription error", error: error) continuation.resume(throwing: error) return } - + if let result = result, result.isFinal { let transcript = result.bestTranscription.formattedString AppLogger.shared.info("File transcription completed: \(transcript.count) characters") @@ -227,9 +226,9 @@ class LocalTranscriptionService: NSObject { } } } - + // MARK: - Private Methods - + private func setupAudioSession() async throws { let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.record, mode: .measurement, options: .duckOthers) @@ -244,11 +243,10 @@ extension LocalTranscriptionService: SFSpeechRecognizerDelegate { DispatchQueue.main.async { self.isAvailable = available AppLogger.shared.info("Speech recognizer availability changed: \(available)") - + if !available && self.isTranscribing { self.stopRealtimeTranscription() } } } } - diff --git a/src/mobile/Muesli/Logger.swift b/src/mobile/Muesli/Logger.swift index dcf0b57..7ba75f5 100644 --- a/src/mobile/Muesli/Logger.swift +++ b/src/mobile/Muesli/Logger.swift @@ -10,78 +10,77 @@ import os.log /// Centralized logging service for the Muesli app final class AppLogger { - static let shared = AppLogger() - + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.muesli.app", category: "general") private let dataLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.muesli.app", category: "data") private let uiLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.muesli.app", category: "ui") private let performanceLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.muesli.app", category: "performance") - + private init() {} - + // MARK: - General Logging - + func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { #if DEBUG let fileName = (file as NSString).lastPathComponent logger.debug("[\(fileName):\(line)] \(function) - \(message)") #endif } - + func info(_ message: String) { logger.info("\(message)") } - + func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) { let fileName = (file as NSString).lastPathComponent logger.warning("[\(fileName):\(line)] \(function) - \(message)") } - + func error(_ message: String, error: Error? = nil, file: String = #file, function: String = #function, line: Int = #line) { let fileName = (file as NSString).lastPathComponent let errorDetail = error?.localizedDescription ?? "No error details" logger.error("[\(fileName):\(line)] \(function) - \(message). Error: \(errorDetail)") } - + // MARK: - Data Operations Logging - + func dataOperation(_ operation: String, details: String = "") { dataLogger.info("Data Operation: \(operation) - \(details)") } - + func dataError(_ operation: String, error: Error, details: String = "") { dataLogger.error("Data Error in \(operation): \(error.localizedDescription) - \(details)") } - + func dataSuccess(_ operation: String, details: String = "") { dataLogger.info("✅ Data Success: \(operation) - \(details)") } - + // MARK: - UI Logging - + func uiEvent(_ event: String, details: String = "") { #if DEBUG uiLogger.debug("UI Event: \(event) - \(details)") #endif } - + func userAction(_ action: String, context: String = "") { uiLogger.info("User Action: \(action) - \(context)") } - + // MARK: - Performance Logging - + func performance(_ operation: String, duration: TimeInterval, details: String = "") { - let formattedDuration = String(format: "%.3f", duration * 1000) // Convert to milliseconds + let formattedDuration = String(format: "%.3f", duration * 1_000) // Convert to milliseconds performanceLogger.info("⚡ Performance: \(operation) - \(formattedDuration)ms - \(details)") } - + func performanceStart(_ operation: String) -> Date { performanceLogger.debug("⏱️ Performance Start: \(operation)") return Date() } - + func performanceEnd(_ operation: String, startTime: Date, details: String = "") { let duration = Date().timeIntervalSince(startTime) performance(operation, duration: duration, details: details) @@ -91,28 +90,27 @@ final class AppLogger { // MARK: - Convenience Extensions extension AppLogger { - /// Log note operations with structured data func noteOperation(_ operation: NoteOperation, noteId: String? = nil, title: String? = nil) { let noteInfo = [ noteId.map { "ID: \($0)" }, title.map { "Title: \($0)" } ].compactMap { $0 }.joined(separator: ", ") - + dataOperation(operation.rawValue, details: noteInfo) } - + /// Log search operations func searchOperation(query: String, resultCount: Int, includeArchived: Bool = false) { let context = includeArchived ? "including archived" : "active only" dataOperation("Search", details: "Query: '\(query)', Results: \(resultCount) (\(context))") } - + /// Log app lifecycle events func appLifecycle(_ event: AppLifecycleEvent) { info("App Lifecycle: \(event.rawValue)") } - + /// Log view lifecycle events func viewLifecycle(_ view: String, event: ViewLifecycleEvent) { uiEvent("\(view) - \(event.rawValue)") @@ -142,4 +140,4 @@ enum ViewLifecycleEvent: String { case appear = "View Appear" case disappear = "View Disappear" case load = "View Load" -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Migration/ConferenceMigration.swift b/src/mobile/Muesli/Migration/ConferenceMigration.swift new file mode 100644 index 0000000..08e71b3 --- /dev/null +++ b/src/mobile/Muesli/Migration/ConferenceMigration.swift @@ -0,0 +1,75 @@ +// +// ConferenceMigration.swift +// Muesli +// +// One-time migration that backfills Conference records by grouping +// existing Notes on their legacy `conferenceName` string. Idempotent: +// guarded by a UserDefaults flag, and reuses any pre-existing Conference +// with a matching normalized name. +// + +import Foundation +import SwiftData + +enum ConferenceMigration { + static let runFlagKey = "muesli.conferenceMigration.v1.complete" + + /// Groups notes by `conferenceName` (case-insensitive, whitespace-trimmed) + /// and attaches them to a find-or-created `Conference`. Backfills the + /// conference's startDate/endDate from the min/max note timestamps. + /// Idempotent: safe to call multiple times. + static func run(in context: ModelContext) { + let unattached = (try? context.fetch( + FetchDescriptor(predicate: #Predicate { $0.conference == nil && $0.conferenceName != nil }) + )) ?? [] + + var groups: [String: (display: String, notes: [Note])] = [:] + for note in unattached { + guard let raw = note.conferenceName else { continue } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let key = trimmed.lowercased() + if groups[key] == nil { + groups[key] = (display: trimmed, notes: []) + } + groups[key]?.notes.append(note) + } + + let existing = (try? context.fetch(FetchDescriptor())) ?? [] + var byKey: [String: Conference] = [:] + for conf in existing { + let key = conf.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + byKey[key] = conf + } + + for (key, group) in groups { + let conf: Conference + if let found = byKey[key] { + conf = found + } else { + conf = Conference(name: group.display) + context.insert(conf) + byKey[key] = conf + } + + // group.notes is filtered to `conference == nil`, so no overlap with conf.notes. + for note in group.notes { + note.conference = conf + } + let timestamps = (conf.notes + group.notes).map(\.timestamp) + conf.startDate = timestamps.min() ?? conf.startDate + conf.endDate = timestamps.max() ?? conf.endDate + } + + do { + try context.save() + UserDefaults.standard.set(true, forKey: runFlagKey) + } catch { + AppLogger.shared.error("ConferenceMigration save failed; will retry on next launch", error: error) + } + } + + static var hasRun: Bool { + UserDefaults.standard.bool(forKey: runFlagKey) + } +} diff --git a/src/mobile/Muesli/Migration/PhotoMigration.swift b/src/mobile/Muesli/Migration/PhotoMigration.swift index 32a109c..60ece4a 100644 --- a/src/mobile/Muesli/Migration/PhotoMigration.swift +++ b/src/mobile/Muesli/Migration/PhotoMigration.swift @@ -38,8 +38,12 @@ enum PhotoMigration { note.photos.append(photo) } } - try? context.save() - UserDefaults.standard.set(true, forKey: runFlagKey) + do { + try context.save() + UserDefaults.standard.set(true, forKey: runFlagKey) + } catch { + AppLogger.shared.error("PhotoMigration save failed; will retry on next launch", error: error) + } } static var hasRun: Bool { diff --git a/src/mobile/Muesli/Models.swift b/src/mobile/Muesli/Models.swift index cb3f445..bd2e83c 100644 --- a/src/mobile/Muesli/Models.swift +++ b/src/mobile/Muesli/Models.swift @@ -27,6 +27,9 @@ final class Note { var aiSummary: String? // AI-generated summary of the transcript var userNotes: String = "" // User's personal notes added during or after recording + // Speaker shown in the augmented note view; user-provided or transcriber-derived. + var speaker: String? + // Blend pipeline outputs (populated post-stop) var transcript: String? var transcriptWordsJSON: Data? @@ -37,9 +40,19 @@ final class Note { var blendError: String? var blendCostMicros: Int? var blendModelVersion: String? + /// The UUID the backend assigned for this note's session (from + /// `sessionsRepo.createSession`). Set by `BlendOrchestrator` once the + /// upload + blend cycle starts; used by chat routes to address the + /// backend's stored transcript / blended content. Nil for notes that + /// haven't been through the blend pipeline yet. + var backendSessionId: UUID? @Relationship(deleteRule: .cascade, inverse: \Photo.note) var photos: [Photo] = [] + // Conference grouping. Replaces conferenceName at the read site; + // conferenceName is retained for one release as a fallback. + var conference: Conference? + var blendStatus: BlendStatus { get { BlendStatus(rawValue: blendStatusRaw) ?? .idle } set { blendStatusRaw = newValue.rawValue } @@ -58,7 +71,9 @@ final class Note { duration: TimeInterval? = nil, imagePaths: [String] = [], aiSummary: String? = nil, - userNotes: String = "" + userNotes: String = "", + speaker: String? = nil, + conference: Conference? = nil ) { self.id = id self.title = title @@ -73,8 +88,10 @@ final class Note { self.imagePaths = imagePaths self.aiSummary = aiSummary self.userNotes = userNotes + self.speaker = speaker + self.conference = conference } - + // Computed properties for UI display var timeString: String { let formatter = DateFormatter() @@ -82,29 +99,29 @@ final class Note { formatter.locale = Locale(identifier: "en_US") // Ensure AM/PM format for tests return formatter.string(from: timestamp) } - + var dateString: String { let formatter = DateFormatter() formatter.dateFormat = "E d MMM yyyy" // Include year for tests formatter.locale = Locale(identifier: "en_US") // Ensure consistent format return formatter.string(from: timestamp) } - + var durationString: String { guard let duration = duration else { return "00:00" } let minutes = Int(duration) / 60 let seconds = Int(duration) % 60 return String(format: "%02d:%02d", minutes, seconds) } - + var hasAudio: Bool { return audioFilePath != nil } - + var needsTranscription: Bool { return hasAudio && (transcriptionStatus == "none" || transcriptionStatus == "failed") } - + var isTranscribing: Bool { return transcriptionStatus == "processing" } @@ -116,6 +133,13 @@ final class Note { var imageCount: Int { return imagePaths.count } + + /// Conference name preferring the `Conference` relationship over the + /// legacy `conferenceName` string. New UI should always read this. + /// `conferenceName` is retained for one release as a migration fallback. + var resolvedConferenceName: String? { + conference?.name ?? conferenceName + } } // MARK: - Note Model Extensions and Utilities diff --git a/src/mobile/Muesli/Models/ChatMessage.swift b/src/mobile/Muesli/Models/ChatMessage.swift new file mode 100644 index 0000000..6d75901 --- /dev/null +++ b/src/mobile/Muesli/Models/ChatMessage.swift @@ -0,0 +1,44 @@ +// +// ChatMessage.swift +// Muesli +// +// SwiftData entity for a single chat message within a ChatThread. +// + +import Foundation +import SwiftData + +enum ChatRole: String, Codable { + case user, assistant +} + +@Model +final class ChatMessage { + var id: UUID + var roleRaw: String + var content: String + var citationsJSON: Data? + var createdAt: Date + var thread: ChatThread? + + var role: ChatRole { + get { ChatRole(rawValue: roleRaw) ?? .user } + set { roleRaw = newValue.rawValue } + } + + init( + id: UUID = UUID(), + role: ChatRole, + content: String, + citationsJSON: Data? = nil, + createdAt: Date = Date(), + thread: ChatThread? = nil + ) { + self.id = id + self.roleRaw = role.rawValue + self.content = content + self.citationsJSON = citationsJSON + self.createdAt = createdAt + self.thread = thread + } +} diff --git a/src/mobile/Muesli/Models/ChatThread.swift b/src/mobile/Muesli/Models/ChatThread.swift new file mode 100644 index 0000000..ecb1dff --- /dev/null +++ b/src/mobile/Muesli/Models/ChatThread.swift @@ -0,0 +1,44 @@ +// +// ChatThread.swift +// Muesli +// +// SwiftData entity for a chat conversation, scoped to either a talk or a conference. +// + +import Foundation +import SwiftData + +enum ChatScopeKind: String, Codable { + case talk, conference +} + +@Model +final class ChatThread { + var id: UUID + var scopeKindRaw: String + var scopeId: UUID + var createdAt: Date + var updatedAt: Date + + @Relationship(deleteRule: .cascade, inverse: \ChatMessage.thread) + var messages: [ChatMessage] = [] + + var scopeKind: ChatScopeKind { + get { ChatScopeKind(rawValue: scopeKindRaw) ?? .talk } + set { scopeKindRaw = newValue.rawValue } + } + + init( + id: UUID = UUID(), + scopeKind: ChatScopeKind, + scopeId: UUID, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.id = id + self.scopeKindRaw = scopeKind.rawValue + self.scopeId = scopeId + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/src/mobile/Muesli/Models/Conference.swift b/src/mobile/Muesli/Models/Conference.swift new file mode 100644 index 0000000..8f4df9c --- /dev/null +++ b/src/mobile/Muesli/Models/Conference.swift @@ -0,0 +1,41 @@ +// +// Conference.swift +// Muesli +// +// SwiftData entity representing a conference, grouping multiple Note talks. +// + +import Foundation +import SwiftData + +@Model +final class Conference { + var id: UUID + var name: String + var location: String? + var startDate: Date? + var endDate: Date? + var conferenceDescription: String? // `description` is reserved on NSObject + var createdAt: Date + + @Relationship(deleteRule: .nullify, inverse: \Note.conference) + var notes: [Note] = [] + + init( + id: UUID = UUID(), + name: String, + location: String? = nil, + startDate: Date? = nil, + endDate: Date? = nil, + conferenceDescription: String? = nil, + createdAt: Date = Date() + ) { + self.id = id + self.name = name + self.location = location + self.startDate = startDate + self.endDate = endDate + self.conferenceDescription = conferenceDescription + self.createdAt = createdAt + } +} diff --git a/src/mobile/Muesli/MuesliApp.swift b/src/mobile/Muesli/MuesliApp.swift index dfe8b72..435f62c 100644 --- a/src/mobile/Muesli/MuesliApp.swift +++ b/src/mobile/Muesli/MuesliApp.swift @@ -14,6 +14,9 @@ struct MuesliApp: App { let schema = Schema([ Note.self, Photo.self, + Conference.self, + ChatThread.self, + ChatMessage.self ]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) @@ -22,7 +25,7 @@ struct MuesliApp: App { } catch { // Log the error but continue with in-memory fallback AppLogger.shared.error("SwiftData container creation failed, using in-memory fallback", error: error) - + // Fallback to in-memory storage let fallbackConfig = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) do { @@ -47,11 +50,16 @@ struct MuesliApp: App { return try? Data(contentsOf: url) }) } + + if !ConferenceMigration.hasRun { + let context = ModelContext(sharedModelContainer) + ConferenceMigration.run(in: context) + } } var body: some Scene { WindowGroup { - SimpleMainView() + MainView() } .modelContainer(sharedModelContainer) } diff --git a/src/mobile/Muesli/NetworkMonitor.swift b/src/mobile/Muesli/NetworkMonitor.swift index e7e3a64..e1e12cf 100644 --- a/src/mobile/Muesli/NetworkMonitor.swift +++ b/src/mobile/Muesli/NetworkMonitor.swift @@ -16,27 +16,26 @@ enum NetworkStatus { } @Observable -class NetworkMonitor { - +class NetworkMonitor: NetworkPort { static let shared = NetworkMonitor() - + private let monitor = NWPathMonitor() private let queue = DispatchQueue(label: "NetworkMonitor") - + private(set) var status: NetworkStatus = .unknown private(set) var isConnected: Bool = false private(set) var connectionType: NWInterface.InterfaceType? - + private init() { startMonitoring() } - + deinit { stopMonitoring() } - + // MARK: - Public Methods - + func startMonitoring() { monitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { @@ -46,23 +45,23 @@ class NetworkMonitor { monitor.start(queue: queue) AppLogger.shared.info("Started network monitoring") } - + func stopMonitoring() { monitor.cancel() AppLogger.shared.info("Stopped network monitoring") } - + func checkConnectivity() async -> Bool { // Quick connectivity test guard isConnected else { return false } - + return await withCheckedContinuation { continuation in let url = URL(string: "https://api.deepgram.com/v1/listen")! var request = URLRequest(url: url) request.httpMethod = "HEAD" request.timeoutInterval = 5.0 - - let task = URLSession.shared.dataTask(with: request) { _, response, error in + + let task = URLSession.shared.dataTask(with: request) { _, response, _ in DispatchQueue.main.async { if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode < 500 { @@ -75,15 +74,15 @@ class NetworkMonitor { task.resume() } } - + // MARK: - Private Methods - + private func updateNetworkStatus(path: NWPath) { let wasConnected = isConnected - + isConnected = path.status == .satisfied connectionType = path.availableInterfaces.first?.type - + switch path.status { case .satisfied: status = .connected @@ -92,7 +91,7 @@ class NetworkMonitor { @unknown default: status = .unknown } - + // Log connectivity changes if wasConnected != isConnected { if isConnected { diff --git a/src/mobile/Muesli/PerformanceMonitor.swift b/src/mobile/Muesli/PerformanceMonitor.swift index c4448fd..237cec0 100644 --- a/src/mobile/Muesli/PerformanceMonitor.swift +++ b/src/mobile/Muesli/PerformanceMonitor.swift @@ -11,26 +11,25 @@ import Combine /// Performance monitoring service for tracking app performance metrics final class PerformanceMonitor: ObservableObject { - static let shared = PerformanceMonitor() - - @Published private(set) var metrics: PerformanceMetrics = PerformanceMetrics() - + + @Published private(set) var metrics = PerformanceMetrics() + private var operationTimers: [String: Date] = [:] private var cancellables = Set() - + private init() { startMemoryMonitoring() } - + // MARK: - Performance Timing - + /// Start timing a performance-critical operation func startTiming(operation: String) { operationTimers[operation] = Date() _ = AppLogger.shared.performanceStart(operation) } - + /// End timing and log the performance metric @discardableResult func endTiming(operation: String, recordMetric: Bool = true) -> TimeInterval? { @@ -38,17 +37,17 @@ final class PerformanceMonitor: ObservableObject { AppLogger.shared.warning("No start time found for operation: \(operation)") return nil } - + let duration = Date().timeIntervalSince(startTime) AppLogger.shared.performanceEnd(operation, startTime: startTime) - + if recordMetric { recordOperationMetric(operation: operation, duration: duration) } - + return duration } - + /// Execute a closure and measure its performance @discardableResult func measure(operation: String, _ closure: () throws -> T) rethrows -> T { @@ -56,7 +55,7 @@ final class PerformanceMonitor: ObservableObject { defer { endTiming(operation: operation) } return try closure() } - + /// Execute an async closure and measure its performance @discardableResult func measureAsync(operation: String, _ closure: () async throws -> T) async rethrows -> T { @@ -64,13 +63,13 @@ final class PerformanceMonitor: ObservableObject { defer { endTiming(operation: operation) } return try await closure() } - + // MARK: - Metrics Recording - + private func recordOperationMetric(operation: String, duration: TimeInterval) { DispatchQueue.main.async { var newMetrics = self.metrics - + switch operation { case let op where op.contains("Fetch"): newMetrics.dataOperations.append( @@ -89,15 +88,15 @@ final class PerformanceMonitor: ObservableObject { GeneralOperationMetric(operation: operation, duration: duration, timestamp: Date()) ) } - + // Keep only recent metrics (last 100 operations per type) newMetrics.cleanup() self.metrics = newMetrics } } - + // MARK: - Memory Monitoring - + private func startMemoryMonitoring() { Timer.publish(every: 30.0, on: .main, in: .common) .autoconnect() @@ -106,11 +105,11 @@ final class PerformanceMonitor: ObservableObject { } .store(in: &cancellables) } - + func updateMemoryUsage() { recordMemoryUsage() } - + private func recordMemoryUsage() { let memoryUsage = getMemoryUsage() DispatchQueue.main.async { @@ -122,11 +121,11 @@ final class PerformanceMonitor: ObservableObject { self.metrics = newMetrics } } - + private func getMemoryUsage() -> Double { var info = mach_task_basic_info() - var count = mach_msg_type_number_t(MemoryLayout.size)/4 - + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { $0.withMemoryRebound(to: integer_t.self, capacity: 1) { task_info(mach_task_self_, @@ -135,45 +134,45 @@ final class PerformanceMonitor: ObservableObject { &count) } } - + if kerr == KERN_SUCCESS { - return Double(info.resident_size) / 1024.0 / 1024.0 // Convert to MB + return Double(info.resident_size) / 1_024.0 / 1_024.0 // Convert to MB } else { return 0 } } - + // MARK: - Performance Reports - + func generatePerformanceReport() -> String { - let dataOpsAvg = metrics.dataOperations.isEmpty ? 0 : + let dataOpsAvg = metrics.dataOperations.isEmpty ? 0 : metrics.dataOperations.map(\.duration).reduce(0, +) / Double(metrics.dataOperations.count) - + let searchOpsAvg = metrics.searchOperations.isEmpty ? 0 : metrics.searchOperations.map(\.duration).reduce(0, +) / Double(metrics.searchOperations.count) - + let writeOpsAvg = metrics.writeOperations.isEmpty ? 0 : metrics.writeOperations.map(\.duration).reduce(0, +) / Double(metrics.writeOperations.count) - + let currentMemory = metrics.memoryUsage.last?.usageMB ?? 0 let avgMemory = metrics.memoryUsage.isEmpty ? 0 : metrics.memoryUsage.map(\.usageMB).reduce(0, +) / Double(metrics.memoryUsage.count) - + return """ 📊 Performance Report - + Data Operations: • Count: \(metrics.dataOperations.count) - • Average Duration: \(String(format: "%.2f", dataOpsAvg * 1000))ms - + • Average Duration: \(String(format: "%.2f", dataOpsAvg * 1_000))ms + Search Operations: • Count: \(metrics.searchOperations.count) - • Average Duration: \(String(format: "%.2f", searchOpsAvg * 1000))ms - + • Average Duration: \(String(format: "%.2f", searchOpsAvg * 1_000))ms + Write Operations: • Count: \(metrics.writeOperations.count) - • Average Duration: \(String(format: "%.2f", writeOpsAvg * 1000))ms - + • Average Duration: \(String(format: "%.2f", writeOpsAvg * 1_000))ms + Memory Usage: • Current: \(String(format: "%.1f", currentMemory))MB • Average: \(String(format: "%.1f", avgMemory))MB @@ -189,7 +188,7 @@ struct PerformanceMetrics { var writeOperations: [WriteOperationMetric] = [] var generalOperations: [GeneralOperationMetric] = [] var memoryUsage: [MemoryUsageMetric] = [] - + mutating func cleanup() { // Keep only the last 100 entries for each type if dataOperations.count > 100 { @@ -238,4 +237,3 @@ struct MemoryUsageMetric { let usageMB: Double let timestamp: Date } - diff --git a/src/mobile/Muesli/Ports/BlendPort.swift b/src/mobile/Muesli/Ports/BlendPort.swift new file mode 100644 index 0000000..b26e49c --- /dev/null +++ b/src/mobile/Muesli/Ports/BlendPort.swift @@ -0,0 +1,27 @@ +// +// BlendPort.swift +// Muesli +// +// Port (interface) for the backend session + blend API. Live adapter +// is the actor-based SessionsService talking to the Node backend; test +// fakes return canned BlendResponse objects. +// + +import Foundation + +/// Value-type DTO so callers can hand photo metadata across actor +/// boundaries without dragging the SwiftData `Photo` model with them. +/// Required for Sendable-correct crossings under Swift 6 strict concurrency. +struct PhotoUpload: Sendable { + let photoId: UUID + let contentHash: String + let capturedAt: Date + let jpegData: Data +} + +protocol BlendPort: Sendable { + func createSession() async throws -> UUID + func uploadAudio(sessionId: UUID, audioURL: URL, durationSeconds: Double) async throws + func uploadPhoto(sessionId: UUID, upload: PhotoUpload) async throws -> PhotoResponse + func runBlend(sessionId: UUID, userNotes: String) async throws -> BlendResponse +} diff --git a/src/mobile/Muesli/Ports/ChatPort.swift b/src/mobile/Muesli/Ports/ChatPort.swift new file mode 100644 index 0000000..2ded04a --- /dev/null +++ b/src/mobile/Muesli/Ports/ChatPort.swift @@ -0,0 +1,60 @@ +// +// ChatPort.swift +// Muesli +// +// Port (interface) for chat. Live adapter will be added in Phase 6 +// (chat backend); for now the live composition uses an unavailable +// placeholder that throws ChatPortError.notImplemented. +// + +import Foundation + +struct ChatTurn: Codable, Sendable, Equatable { + let role: String // "user" | "assistant" + let content: String +} + +enum ChatCitationKind: String, Codable, Sendable { + case transcript, note +} + +struct ChatCitation: Codable, Sendable, Equatable { + let kind: ChatCitationKind + let talkId: UUID? + let noteId: UUID? + let startSec: Double? + let endSec: Double? + let label: String? + let title: String? +} + +struct ChatResponse: Codable, Sendable, Equatable { + let message: ChatTurn + let citations: [ChatCitation] +} + +enum ChatScope { + case talk(UUID) + case conference(UUID) +} + +enum ChatPortError: Error, LocalizedError { + case notImplemented + + var errorDescription: String? { + switch self { + case .notImplemented: return "Chat is not implemented yet." + } + } +} + +protocol ChatPort: Sendable { + func send(scope: ChatScope, messages: [ChatTurn]) async throws -> ChatResponse +} + +/// Live placeholder until Phase 6 lands the chat backend + iOS adapter. +struct UnimplementedChatAdapter: ChatPort { + func send(scope: ChatScope, messages: [ChatTurn]) async throws -> ChatResponse { + throw ChatPortError.notImplemented + } +} diff --git a/src/mobile/Muesli/Ports/HybridTranscriptionPort.swift b/src/mobile/Muesli/Ports/HybridTranscriptionPort.swift new file mode 100644 index 0000000..cdbc9bc --- /dev/null +++ b/src/mobile/Muesli/Ports/HybridTranscriptionPort.swift @@ -0,0 +1,15 @@ +// +// HybridTranscriptionPort.swift +// Muesli +// +// Port for batch / file transcription that may use local or cloud +// implementations. Separate from TranscriptionPort because the file +// transcription contract throws and returns a non-optional String, +// while the realtime port returns a Bool / optional. +// + +import Foundation + +protocol HybridTranscriptionPort: AnyObject { + func transcribeAudioFile(url: URL) async throws -> String +} diff --git a/src/mobile/Muesli/Ports/NetworkPort.swift b/src/mobile/Muesli/Ports/NetworkPort.swift new file mode 100644 index 0000000..d90210e --- /dev/null +++ b/src/mobile/Muesli/Ports/NetworkPort.swift @@ -0,0 +1,15 @@ +// +// NetworkPort.swift +// Muesli +// +// Port (interface) for network reachability. Live adapter wraps +// NWPathMonitor; tests use a fake that returns canned isConnected values. +// + +import Foundation + +protocol NetworkPort: AnyObject { + var isConnected: Bool { get } + func startMonitoring() + func stopMonitoring() +} diff --git a/src/mobile/Muesli/Ports/TranscriptionPort.swift b/src/mobile/Muesli/Ports/TranscriptionPort.swift new file mode 100644 index 0000000..78060b9 --- /dev/null +++ b/src/mobile/Muesli/Ports/TranscriptionPort.swift @@ -0,0 +1,25 @@ +// +// TranscriptionPort.swift +// Muesli +// +// Port (interface) for transcription services. Live adapters wrap +// the Deepgram / on-device implementations; test fakes return canned +// responses so tests never touch the real network. +// + +import Foundation + +protocol TranscriptionPort: AnyObject { + var isTranscribing: Bool { get } + var hasValidAPIEndpoint: Bool { get } + var environmentName: String { get } + var currentAPIEndpoint: String { get } + var isUsingLocalhost: Bool { get } + + var onError: ((Error) -> Void)? { get set } + var onTranscriptionUpdate: ((TranscriptionResult) -> Void)? { get set } + + func startRealtimeTranscription() async -> Bool + func stopRealtimeTranscription() + func transcribeAudioFile(url: URL) async -> String? +} diff --git a/src/mobile/Muesli/SampleData/SampleDataManager.swift b/src/mobile/Muesli/SampleData/SampleDataManager.swift index 6d5e4d9..dc36cfb 100644 --- a/src/mobile/Muesli/SampleData/SampleDataManager.swift +++ b/src/mobile/Muesli/SampleData/SampleDataManager.swift @@ -10,118 +10,164 @@ import SwiftData #if DEBUG struct SampleDataManager { - // MARK: - Sample Data Generation - + static func seedDatabase(context: ModelContext) { - let sampleNotes = generateSampleNotes() - + let conferences = generateSampleConferences() + conferences.forEach(context.insert) + + let dataSummit = conferences[0] + let devWorld = conferences[1] + let sampleNotes = generateSampleNotes(dataSummit: dataSummit, devWorld: devWorld) + for note in sampleNotes { context.insert(note) } - + do { try context.save() - AppLogger.shared.dataSuccess("Sample Data", details: "Seeded \(sampleNotes.count) sample notes") + AppLogger.shared.dataSuccess( + "Sample Data", + details: "Seeded \(conferences.count) conferences and \(sampleNotes.count) notes" + ) } catch { AppLogger.shared.dataError("Sample Data", error: error) } } - - static func generateSampleNotes() -> [Note] { + + static func generateSampleConferences() -> [Conference] { + let cal = Calendar.current + let dataSummit = Conference( + name: "DataSummit 2026", + location: "San Francisco, CA", + startDate: cal.date(from: DateComponents(year: 2_026, month: 5, day: 10)), + endDate: cal.date(from: DateComponents(year: 2_026, month: 5, day: 12)), + conferenceDescription: "Annual data and ML conference" + ) + let devWorld = Conference( + name: "DevWorld 2026", + location: "Austin, TX", + startDate: cal.date(from: DateComponents(year: 2_026, month: 3, day: 14)), + endDate: cal.date(from: DateComponents(year: 2_026, month: 3, day: 16)), + conferenceDescription: "Developer conference covering web, mobile, and platforms" + ) + return [dataSummit, devWorld] + } + + static func generateSampleNotes(dataSummit: Conference, devWorld: Conference) -> [Note] { let baseTime = Date() - + return [ - // Basic notes + // DataSummit 2026 talks (3) Note( - title: "Team Standup", - content: "Discussed current sprint progress. John is working on the API integration, Sarah is finishing the UI components. Need to review the deployment pipeline by Friday.", - timestamp: baseTime.addingTimeInterval(-3600), // 1 hour ago - conferenceName: nil, - sessionType: "meeting", + title: "The three pillars of data infra", + content: "Storage, compute, and discoverability. Sarah walked through how DataSummit's flagship team rebuilt their lake-house on these primitives.", + timestamp: baseTime.addingTimeInterval(-3_600), + conferenceName: "DataSummit 2026", + sessionType: "session", isArchived: false, - audioFilePath: nil, - transcriptionStatus: "none", - duration: 0 - ), - + audioFilePath: "sample_three_pillars.m4a", + transcriptionStatus: "completed", + duration: 2_400, + speaker: "Sarah Chen", + conference: dataSummit + ).withSeededBackendSessionId(), Note( - title: "Feature Ideas", - content: "Brainstormed some interesting features:\n• Dark mode toggle\n• Export functionality\n• Collaboration features\n• Voice notes integration", - timestamp: baseTime.addingTimeInterval(-7200), // 2 hours ago - conferenceName: nil, - sessionType: "brainstorm", + title: "Streaming at planet scale", + content: "Devon's deep dive on multi-region streaming, exactly-once semantics, and the operational realities they hit at year three.", + timestamp: baseTime.addingTimeInterval(-7_200), + conferenceName: "DataSummit 2026", + sessionType: "session", isArchived: false, - audioFilePath: nil, - transcriptionStatus: "none", - duration: 0 - ), - + audioFilePath: "sample_streaming.m4a", + transcriptionStatus: "completed", + duration: 2_700, + speaker: "Devon Park", + conference: dataSummit + ).withSeededBackendSessionId(), Note( - title: "Client Feedback", - content: "Client loved the new interface design. Requested some minor adjustments to the color scheme and font sizing. Overall very positive response.", - timestamp: baseTime.addingTimeInterval(-86400), // 1 day ago - conferenceName: nil, - sessionType: "client-meeting", + title: "Embeddings for everything", + content: "Hina's plenary on using embeddings as the universal interface across retrieval, ranking, and dedup.", + timestamp: baseTime.addingTimeInterval(-90_000), + conferenceName: "DataSummit 2026", + sessionType: "session", isArchived: false, - audioFilePath: nil, + audioFilePath: "sample_embeddings.m4a", transcriptionStatus: "completed", - duration: 1800 // 30 minutes - ), - - // Note with transcription + duration: 3_000, + speaker: "Hina Yoshida", + conference: dataSummit + ).withSeededBackendSessionId(), + + // DevWorld 2026 talks (2) Note( - title: "Architecture Review", - content: "Reviewed the current system architecture. The microservices approach is working well, but we need to optimize the database queries. Consider implementing caching layer.", - timestamp: baseTime.addingTimeInterval(-172800), // 2 days ago - conferenceName: "Tech Architecture", - sessionType: "technical-review", + title: "SwiftUI performance audit", + content: "A pragmatic tour of Instruments for SwiftUI, view identity, and the diff cost of large lists.", + timestamp: baseTime.addingTimeInterval(-5_184_000), + conferenceName: "DevWorld 2026", + sessionType: "session", isArchived: false, - audioFilePath: "sample_architecture_review.m4a", + audioFilePath: "sample_swiftui_perf.m4a", transcriptionStatus: "completed", - duration: 2400 // 40 minutes + duration: 1_800, + speaker: "Aiden Reyes", + conference: devWorld + ).withSeededBackendSessionId(), + Note( + title: "Edge runtimes in practice", + content: "What works, what doesn't, and the boring middle of running production services at the edge.", + timestamp: baseTime.addingTimeInterval(-5_270_400), + conferenceName: "DevWorld 2026", + sessionType: "session", + isArchived: false, + audioFilePath: nil, + transcriptionStatus: "none", + duration: 0, + speaker: "Priya Iyer", + conference: devWorld + ).withSeededBackendSessionId(), + + // Ungrouped notes (preserved for non-conference flows) + Note( + title: "Team Standup", + content: "Discussed current sprint progress. John is working on the API integration, Sarah is finishing the UI components.", + timestamp: baseTime.addingTimeInterval(-1_800), + conferenceName: nil, + sessionType: "meeting", + isArchived: false, + audioFilePath: nil, + transcriptionStatus: "none", + duration: 0 ), - - // Archived note Note( title: "Old Project Notes", - content: "Legacy project documentation that's no longer active but kept for reference. Contains important historical decisions and rationale.", - timestamp: baseTime.addingTimeInterval(-604800), // 1 week ago + content: "Legacy project documentation that's no longer active but kept for reference.", + timestamp: baseTime.addingTimeInterval(-604_800), conferenceName: nil, sessionType: "documentation", isArchived: true, audioFilePath: nil, transcriptionStatus: "none", duration: 0 - ), - - // Note with failed transcription - Note( - title: "Quick Voice Note", - content: "This was recorded as a voice note but transcription failed. Needs to be reprocessed.", - timestamp: baseTime.addingTimeInterval(-1800), // 30 minutes ago - conferenceName: nil, - sessionType: "voice-note", - isArchived: false, - audioFilePath: "quick_voice_note.m4a", - transcriptionStatus: "failed", - duration: 120 // 2 minutes ) ] } - + // MARK: - Utility Methods - + static func clearAllData(context: ModelContext) { do { + try context.delete(model: ChatMessage.self) + try context.delete(model: ChatThread.self) try context.delete(model: Note.self) + try context.delete(model: Conference.self) try context.save() AppLogger.shared.dataSuccess("Sample Data", details: "Cleared all data") } catch { AppLogger.shared.dataError("Sample Data Clear", error: error) } } - + static func reseedDatabase(context: ModelContext) { clearAllData(context: context) seedDatabase(context: context) @@ -140,4 +186,14 @@ extension SampleDataManager { } } -#endif \ No newline at end of file +private extension Note { + /// Sample-data helper: assigns a deterministic backendSessionId so chat + /// against a backend that has the same seed rows works without a real + /// blend round-trip. Production notes get this from BlendOrchestrator. + func withSeededBackendSessionId() -> Note { + self.backendSessionId = self.id + return self + } +} + +#endif diff --git a/src/mobile/Muesli/Services/AuthService.swift b/src/mobile/Muesli/Services/AuthService.swift new file mode 100644 index 0000000..9b72efe --- /dev/null +++ b/src/mobile/Muesli/Services/AuthService.swift @@ -0,0 +1,110 @@ +// +// AuthService.swift +// Muesli +// +// Backend auth surface: dev sign-in (POST /v1/auth/dev), refresh +// (POST /v1/auth/refresh), and signOut. Tokens are persisted to +// TokenStore so SessionsService and LiveChatAdapter pick them up. +// + +import Foundation + +struct AuthUser: Codable, Equatable { + let id: UUID + let email: String + let fullName: String? +} + +enum AuthError: Error, LocalizedError { + case http(status: Int, message: String?) + case decodeFailed + + var errorDescription: String? { + switch self { + case .http(let status, let msg): + return msg ?? "Authentication failed (HTTP \(status))." + case .decodeFailed: + return "Couldn't read the server response." + } + } +} + +actor AuthService { + static let shared = AuthService( + baseURL: APIConfiguration.baseURL, + session: .shared, + store: TokenStore.shared + ) + + private let baseURL: URL + private let session: URLSession + private let store: TokenStore + + init(baseURL: URL, session: URLSession, store: TokenStore) { + self.baseURL = baseURL + self.session = session + self.store = store + } + + /// Dev sign-in. Available only when the backend is in non-production. + @discardableResult + func signInDev(email: String, fullName: String? = nil) async throws -> AuthUser { + struct Body: Encodable { let email: String; let fullName: String? } + let envelope: AuthEnvelope = try await post( + path: "/v1/auth/dev", + body: Body(email: email, fullName: fullName) + ) + await store.setTokens(access: envelope.accessToken, refresh: envelope.refreshToken) + return envelope.user + } + + /// Refresh the access token using the stored refresh token. Returns the + /// new access token; persists both. Throws if the refresh token is + /// invalid or revoked. + @discardableResult + func refreshAccessToken() async throws -> String { + guard let refresh = await store.refreshToken else { + throw AuthError.http(status: 401, message: "No refresh token.") + } + struct Body: Encodable { let refreshToken: String } + struct Resp: Decodable { let accessToken: String; let refreshToken: String } + let resp: Resp = try await post(path: "/v1/auth/refresh", body: Body(refreshToken: refresh)) + await store.setTokens(access: resp.accessToken, refresh: resp.refreshToken) + return resp.accessToken + } + + func signOut() async { + await store.clear() + } + + /// Whether the store currently has an access token. + func isSignedIn() async -> Bool { + await store.accessToken?.isEmpty == false + } + + // MARK: - Private + + private struct AuthEnvelope: Decodable { + let accessToken: String + let refreshToken: String + let user: AuthUser + } + + private func post(path: String, body: B) async throws -> R { + var req = URLRequest(url: baseURL.appendingPathComponent(path)) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + let serverMessage = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["error"] as? String + throw AuthError.http(status: code, message: serverMessage) + } + do { + return try JSONDecoder().decode(R.self, from: data) + } catch { + throw AuthError.decodeFailed + } + } +} diff --git a/src/mobile/Muesli/Services/BlendOrchestrator.swift b/src/mobile/Muesli/Services/BlendOrchestrator.swift index ddff56a..577df9f 100644 --- a/src/mobile/Muesli/Services/BlendOrchestrator.swift +++ b/src/mobile/Muesli/Services/BlendOrchestrator.swift @@ -59,7 +59,7 @@ final class BlendOrchestrator { return } - let svc = SessionsService.shared + let svc = World.current.blend do { // 1. Status → transcribing @@ -68,9 +68,14 @@ final class BlendOrchestrator { try? context.save() } - // 2. Create backend session + // 2. Create backend session and persist it on the Note so the + // chat routes can address this talk's stored transcript. let sessionId = try await svc.createSession() AppLogger.shared.info("BlendOrchestrator: session created \(sessionId)") + await MainActor.run { + note.backendSessionId = sessionId + try? context.save() + } // 3. Upload audio guard let audioURL = AudioRecordingManager.shared.getRecordingURL(fileName: audioPath) else { @@ -97,15 +102,26 @@ final class BlendOrchestrator { try? context.save() } - // 5. Upload each photo - let photos = await MainActor.run { note.photos } - for photo in photos { - guard let jpeg = try? Data(contentsOf: URL(fileURLWithPath: photo.localPath)) else { - AppLogger.shared.warning("BlendOrchestrator: skipping photo with missing file \(photo.localPath)") - continue + // 5. Upload each photo. Build a Sendable DTO on the main actor + // (the Photo @Model isn't safe to cross actor boundaries) and + // then await the actor-isolated upload with the value type. + let uploads: [PhotoUpload] = await MainActor.run { + note.photos.compactMap { p -> PhotoUpload? in + guard let jpeg = try? Data(contentsOf: URL(fileURLWithPath: p.localPath)) else { + AppLogger.shared.warning("BlendOrchestrator: skipping photo with missing file \(p.localPath)") + return nil + } + return PhotoUpload( + photoId: p.id, + contentHash: p.contentHash, + capturedAt: p.capturedAt, + jpegData: jpeg + ) } + } + for upload in uploads { do { - let resp = try await svc.uploadPhoto(sessionId: sessionId, photo: photo, jpegData: jpeg) + let resp = try await svc.uploadPhoto(sessionId: sessionId, upload: upload) AppLogger.shared.info("BlendOrchestrator: photo uploaded \(resp.photoId)") } catch { AppLogger.shared.warning("BlendOrchestrator: photo upload failed, continuing — \(error.localizedDescription)") @@ -138,7 +154,6 @@ final class BlendOrchestrator { note.blendError = nil try? context.save() } - } catch { // 9. On any error → status .failed await MainActor.run { diff --git a/src/mobile/Muesli/Services/SessionsService.swift b/src/mobile/Muesli/Services/SessionsService.swift index b2d89e8..89a3d3d 100644 --- a/src/mobile/Muesli/Services/SessionsService.swift +++ b/src/mobile/Muesli/Services/SessionsService.swift @@ -43,7 +43,7 @@ struct BlendResponse: Decodable { let costMicros: Int } -actor SessionsService { +actor SessionsService: BlendPort { static let shared = SessionsService() private let session = URLSession.shared private let decoder: JSONDecoder = { @@ -55,10 +55,38 @@ actor SessionsService { private var baseURL: URL { APIConfig.baseURL } + /// Apply `Authorization: Bearer …` if TokenStore has a token. Used by + /// every outbound request so backends with AUTH_ENABLED=true accept them. + private func authorize(_ req: inout URLRequest) async { + if let token = await TokenStore.shared.accessToken, !token.isEmpty { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + } + + /// Issue the request; on a 401, refresh once and retry. Anything else + /// passes through to the caller for normal decode / error handling. + private func dataWithRefresh(for req: URLRequest) async throws -> (Data, URLResponse) { + var request = req + await authorize(&request) + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 401 else { + return (data, response) + } + // Try refreshing the access token; if that throws, surface the 401. + do { + _ = try await AuthService.shared.refreshAccessToken() + } catch { + return (data, response) + } + var retry = req + await authorize(&retry) + return try await session.data(for: retry) + } + func createSession() async throws -> UUID { var req = URLRequest(url: baseURL.appendingPathComponent("/v1/sessions")) req.httpMethod = "POST" - let (data, _) = try await session.data(for: req) + let (data, _) = try await dataWithRefresh(for: req) return try decoder.decode(CreateSessionResponse.self, from: data).sessionId } @@ -68,15 +96,15 @@ actor SessionsService { try await uploadMultipart(url: url, fields: ["durationSeconds": String(durationSeconds)], file: (name: "audio", filename: name, mime: mime, data: data)) } - func uploadPhoto(sessionId: UUID, photo: Photo, jpegData: Data) async throws -> PhotoResponse { + func uploadPhoto(sessionId: UUID, upload: PhotoUpload) async throws -> PhotoResponse { let url = baseURL.appendingPathComponent("/v1/sessions/\(sessionId)/photos") let body = try await uploadMultipart( url: url, fields: [ - "photoId": photo.id.uuidString, - "capturedAt": String(Int(photo.capturedAt.timeIntervalSince1970 * 1000)) + "photoId": upload.photoId.uuidString, + "capturedAt": String(Int(upload.capturedAt.timeIntervalSince1970 * 1_000)) ], - file: (name: "photo", filename: "\(photo.contentHash).jpg", mime: "image/jpeg", data: jpegData) + file: (name: "photo", filename: "\(upload.contentHash).jpg", mime: "image/jpeg", data: upload.jpegData) ) return try decoder.decode(PhotoResponse.self, from: body) } @@ -86,7 +114,7 @@ actor SessionsService { req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try encoder.encode(BlendRequest(userNotes: userNotes)) - let (data, _) = try await session.data(for: req) + let (data, _) = try await dataWithRefresh(for: req) return try decoder.decode(BlendResponse.self, from: data) } @@ -110,7 +138,7 @@ actor SessionsService { body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) req.httpBody = body - let (data, _) = try await session.data(for: req) + let (data, _) = try await dataWithRefresh(for: req) return data } } diff --git a/src/mobile/Muesli/Services/TokenStore.swift b/src/mobile/Muesli/Services/TokenStore.swift new file mode 100644 index 0000000..d8dbf11 --- /dev/null +++ b/src/mobile/Muesli/Services/TokenStore.swift @@ -0,0 +1,49 @@ +// +// TokenStore.swift +// Muesli +// +// Holds the access + refresh tokens for backend API calls. v1 stores +// in UserDefaults; a future revision should move to the Keychain. +// +// The Google sign-in UI flow that mints these tokens via /v1/auth/google +// is a separate piece of work; this store gives live adapters a place to +// read from, and `MUESLI_DEV_ACCESS_TOKEN` env var lets developers stamp +// a token in at launch for testing. +// + +import Foundation + +actor TokenStore { + static let shared = TokenStore() + + private static let accessKey = "muesli.auth.accessToken" + private static let refreshKey = "muesli.auth.refreshToken" + + private init() { + // Dev convenience: a token from the environment overrides storage so + // a developer can paste a token in their scheme env vars and use the + // app against an AUTH_ENABLED backend without wiring sign-in. + if let envToken = ProcessInfo.processInfo.environment["MUESLI_DEV_ACCESS_TOKEN"], + !envToken.isEmpty { + UserDefaults.standard.set(envToken, forKey: Self.accessKey) + } + } + + var accessToken: String? { + UserDefaults.standard.string(forKey: Self.accessKey) + } + + var refreshToken: String? { + UserDefaults.standard.string(forKey: Self.refreshKey) + } + + func setTokens(access: String, refresh: String) { + UserDefaults.standard.set(access, forKey: Self.accessKey) + UserDefaults.standard.set(refresh, forKey: Self.refreshKey) + } + + func clear() { + UserDefaults.standard.removeObject(forKey: Self.accessKey) + UserDefaults.standard.removeObject(forKey: Self.refreshKey) + } +} diff --git a/src/mobile/Muesli/Services/TranscriptionOrchestrator.swift b/src/mobile/Muesli/Services/TranscriptionOrchestrator.swift index 8e7301c..98332af 100644 --- a/src/mobile/Muesli/Services/TranscriptionOrchestrator.swift +++ b/src/mobile/Muesli/Services/TranscriptionOrchestrator.swift @@ -54,7 +54,7 @@ final class TranscriptionOrchestrator { AppLogger.shared.info("Orchestrator starting batch transcription for '\(note.title)'") do { - let transcript = try await HybridTranscriptionService.shared.transcribeAudioFile(url: audioURL) + let transcript = try await World.current.hybridTranscription.transcribeAudioFile(url: audioURL) note.content = transcript note.transcriptionStatus = "completed" diff --git a/src/mobile/Muesli/SimpleSummaryGenerator.swift b/src/mobile/Muesli/SimpleSummaryGenerator.swift index a8cbd3f..26d452d 100644 --- a/src/mobile/Muesli/SimpleSummaryGenerator.swift +++ b/src/mobile/Muesli/SimpleSummaryGenerator.swift @@ -8,7 +8,6 @@ import Foundation struct SimpleSummaryGenerator { - /// Generates a short title from transcript (first meaningful phrase or timestamp) static func generateTitle(from transcript: String) -> String { guard !transcript.isEmpty else { @@ -58,10 +57,9 @@ struct SimpleSummaryGenerator { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty && $0.count > 10 } - if !sentences.isEmpty { - // Take key sentences (first, middle, last few) - summary += "# Summary\n\n" + summary += "# Summary\n\n" + if !sentences.isEmpty { // Add first sentence (usually the topic) if let first = sentences.first { summary += "• \(first)\n" @@ -80,14 +78,17 @@ struct SimpleSummaryGenerator { if sentences.count > 1, let last = sentences.last, last != sentences.first { summary += "• \(last)\n" } + } else { + // Short transcript fallback: include the whole transcript as one bullet. + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + summary += "• \(trimmed)\n" + } - // Add word count for transcript - let wordCount = transcript.split(separator: " ").count - summary += "\n○ \(wordCount) words transcribed" - - if let duration = estimateDuration(wordCount: wordCount) { - summary += "\n○ ~\(duration) speaking time" - } + // Always emit word count + duration for any non-empty transcript. + let wordCount = transcript.split(separator: " ").count + summary += "\n○ \(wordCount) words transcribed" + if let duration = estimateDuration(wordCount: wordCount) { + summary += "\n○ ~\(duration) speaking time" } } diff --git a/src/mobile/Muesli/TranscriptionService.swift b/src/mobile/Muesli/TranscriptionService.swift index 80794b2..9cad301 100644 --- a/src/mobile/Muesli/TranscriptionService.swift +++ b/src/mobile/Muesli/TranscriptionService.swift @@ -15,7 +15,7 @@ enum TranscriptionError: Error, LocalizedError { case invalidAudioFile case decodingError case serviceUnavailable - + var errorDescription: String? { switch self { case .apiEndpointNotConfigured: @@ -57,115 +57,114 @@ struct DeepgramAlternative: Codable { } @Observable -class TranscriptionService { - +class TranscriptionService: TranscriptionPort { static let shared = TranscriptionService() - + // Configuration private var urlSession: URLSession private var currentAPIBaseURL: String = "" - + // Real-time transcription state private var webSocketTask: URLSessionWebSocketTask? - + // Published properties private(set) var isTranscribing: Bool = false private(set) var currentTranscript: String = "" private(set) var hasValidAPIEndpoint: Bool = false - + // Callbacks var onTranscriptionUpdate: ((TranscriptionResult) -> Void)? var onError: ((Error) -> Void)? - + private init() { let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 config.timeoutIntervalForResource = 300 self.urlSession = URLSession(configuration: config) - + Task { await loadAPIConfiguration() } } - + private func loadAPIConfiguration() async { currentAPIBaseURL = await APIConfiguration.getCurrentAPIURL() await MainActor.run { hasValidAPIEndpoint = !currentAPIBaseURL.isEmpty } } - + // MARK: - Configuration - + var currentAPIEndpoint: String { return currentAPIBaseURL } - + var isUsingLocalhost: Bool { return currentAPIBaseURL.contains("localhost") || currentAPIBaseURL.contains("127.0.0.1") } - + var environmentName: String { return APIConfiguration.environmentName } - + // MARK: - Real-time Transcription - + func startRealtimeTranscription() async -> Bool { guard hasValidAPIEndpoint else { AppLogger.shared.info("Transcription API endpoint not configured - using local recording mode") return false } - + guard NetworkMonitor.shared.isConnected else { AppLogger.shared.warning("Network not available - falling back to local recording") return false } - + // Stop any existing connection first if webSocketTask != nil { AppLogger.shared.info("Stopping existing WebSocket connection before starting new one") stopRealtimeTranscription() } - + // Your API WebSocket endpoint for real-time transcription let urlString = "\(currentAPIBaseURL)/transcribe/realtime" let wsURL = urlString.replacingOccurrences(of: "http://", with: "ws://") - .replacingOccurrences(of: "https://", with: "wss://") - + .replacingOccurrences(of: "https://", with: "wss://") + guard let url = URL(string: wsURL) else { AppLogger.shared.warning("Invalid WebSocket URL: \(wsURL) - falling back to local recording") return false } - + var request = URLRequest(url: url) request.setValue("application/json", forHTTPHeaderField: "Content-Type") // Add timeout for connection request.timeoutInterval = 10.0 - + webSocketTask = urlSession.webSocketTask(with: request) webSocketTask?.resume() - + isTranscribing = true currentTranscript = "" - + // Start listening for messages await startListening() - + AppLogger.shared.info("Started real-time transcription via custom API at: \(wsURL)") return true } - + func stopRealtimeTranscription() { webSocketTask?.cancel(with: .goingAway, reason: nil) webSocketTask = nil isTranscribing = false AppLogger.shared.info("Stopped real-time transcription") } - + func sendAudioData(_ data: Data) async { guard let webSocketTask = webSocketTask else { return } - + do { try await webSocketTask.send(.data(data)) } catch { @@ -173,30 +172,30 @@ class TranscriptionService { onError?(error) } } - + private func startListening() async { - guard let webSocketTask = webSocketTask else { + guard let webSocketTask = webSocketTask else { AppLogger.shared.warning("startListening called but webSocketTask is nil") - return + return } - + do { let message = try await webSocketTask.receive() await handleWebSocketMessage(message) - + // Continue listening if still connected and transcribing if isTranscribing && self.webSocketTask != nil { await startListening() } } catch { AppLogger.shared.info("[TranscriptionService.swift:179] startListening() - WebSocket connection lost - transcription will continue in offline mode") - + // Clean up the connection await MainActor.run { self.isTranscribing = false self.webSocketTask = nil } - + // Don't call onError for normal connection loss - just stop gracefully if let urlError = error as? URLError { switch urlError.code { @@ -211,7 +210,7 @@ class TranscriptionService { } } } - + private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) async { switch message { case .string(let text): @@ -224,22 +223,21 @@ class TranscriptionService { break } } - + private func processTranscriptionResponse(_ jsonString: String) async { do { guard let data = jsonString.data(using: .utf8) else { return } let response = try JSONDecoder().decode(DeepgramResponse.self, from: data) - + if let channel = response.results.channels.first, let alternative = channel.alternatives.first { - let result = TranscriptionResult( text: alternative.transcript, confidence: alternative.confidence, isFinal: true, // Deepgram doesn't explicitly mark final in this format timestamp: Date().timeIntervalSince1970 ) - + await MainActor.run { self.currentTranscript = alternative.transcript self.onTranscriptionUpdate?(result) @@ -249,36 +247,36 @@ class TranscriptionService { AppLogger.shared.error("Failed to decode transcription response", error: error) } } - + // MARK: - Batch Transcription - + func transcribeAudioFile(url: URL) async -> String? { guard hasValidAPIEndpoint else { AppLogger.shared.warning("Transcription API endpoint not configured for batch transcription") return nil } - + guard NetworkMonitor.shared.isConnected else { AppLogger.shared.warning("Network not available for batch transcription") return nil } - + guard let transcriptionURL = URL(string: "\(currentAPIBaseURL)/transcribe") else { AppLogger.shared.warning("Invalid transcription URL for batch transcription") return nil } - + var request = URLRequest(url: transcriptionURL) request.httpMethod = "POST" - + // Create multipart form data for audio file upload let boundary = UUID().uuidString request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - + do { let audioData = try Data(contentsOf: url) var body = Data() - + // Add audio file to form data guard let boundaryStart = "--\(boundary)\r\n".data(using: .utf8), let contentDisposition = "Content-Disposition: form-data; name=\"audio\"; filename=\"recording.wav\"\r\n".data(using: .utf8), @@ -287,23 +285,23 @@ class TranscriptionService { AppLogger.shared.warning("Failed to create form data for batch transcription") return nil } - + body.append(boundaryStart) body.append(contentDisposition) body.append(contentType) body.append(audioData) body.append(boundaryEnd) - + request.httpBody = body - + let (data, response) = try await urlSession.data(for: request) - + guard let httpResponse = response as? HTTPURLResponse, 200...299 ~= httpResponse.statusCode else { AppLogger.shared.warning("Transcription API returned status: \(((response as? HTTPURLResponse)?.statusCode ?? 0))") return nil } - + // Expected JSON response: {"transcript": "transcribed text"} if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let transcript = json["transcript"] as? String { @@ -313,17 +311,15 @@ class TranscriptionService { AppLogger.shared.warning("Failed to decode batch transcription response") return nil } - } catch { AppLogger.shared.warning("Batch transcription failed: \(error.localizedDescription)") return nil } } - + // MARK: - Utility Methods - + func isConfigured() -> Bool { return hasValidAPIEndpoint && NetworkMonitor.shared.isConnected } } - diff --git a/src/mobile/Muesli/ViewModels/ChatViewModel.swift b/src/mobile/Muesli/ViewModels/ChatViewModel.swift new file mode 100644 index 0000000..af73db5 --- /dev/null +++ b/src/mobile/Muesli/ViewModels/ChatViewModel.swift @@ -0,0 +1,113 @@ +// +// ChatViewModel.swift +// Muesli +// +// Owns one ChatThread's send loop. Appends the user message, calls the +// ChatPort, then appends the assistant message with citations. Rolls +// back the user message if the port throws so the thread doesn't show +// an orphan turn. +// + +import Foundation +import SwiftData + +@MainActor +@Observable +final class ChatViewModel { + let thread: ChatThread + let chat: any ChatPort + let context: ModelContext + + private(set) var isSending = false + private(set) var lastError: String? + + init(thread: ChatThread, chat: any ChatPort, context: ModelContext) { + self.thread = thread + self.chat = chat + self.context = context + } + + var messagesSorted: [ChatMessage] { + thread.messages.sorted { $0.createdAt < $1.createdAt } + } + + /// For talk-scope chat returns `note.backendSessionId` if available + /// (falls back to `thread.scopeId` for back-compat / dev seed data). + /// For conference scope returns the conference's notes' backendSessionIds. + private func resolveSessionIds() -> [UUID] { + switch thread.scopeKind { + case .talk: + let scopeId = thread.scopeId + let predicate = #Predicate { $0.id == scopeId } + if let note = try? context.fetch(FetchDescriptor(predicate: predicate)).first { + if let backend = note.backendSessionId { return [backend] } + } + return [scopeId] + case .conference: + let scopeId = thread.scopeId + let predicate = #Predicate { + $0.conference?.id == scopeId && !$0.isArchived + } + let notes = (try? context.fetch(FetchDescriptor(predicate: predicate))) ?? [] + return notes.compactMap { $0.backendSessionId } + } + } + + func send(content: String) async throws { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + isSending = true + lastError = nil + + // Insert; SwiftData's inverse relationship populates thread.messages + // automatically. Do not manually append to avoid duplicates after the + // inverse resolves on save. + let userMsg = ChatMessage(role: .user, content: trimmed, createdAt: Date(), thread: thread) + context.insert(userMsg) + try? context.save() + + let resolvedIds = resolveSessionIds() + let scope: ChatScope + switch thread.scopeKind { + case .talk: + scope = .talk(resolvedIds.first ?? thread.scopeId) + case .conference: + scope = .conference(thread.scopeId) + } + + let history = messagesSorted.map { ChatTurn(role: $0.role.rawValue, content: $0.content) } + + do { + let response: ChatResponse + // For conference scope inject the resolved session IDs via the + // explicit-resolver variant if the port is a LiveChatAdapter. + if case .conference = scope, let live = chat as? LiveChatAdapter { + response = try await live.send( + scope: scope, + messages: history, + sessionIdsResolver: { _ in resolvedIds } + ) + } else { + response = try await chat.send(scope: scope, messages: history) + } + + let assistantMsg = ChatMessage( + role: .assistant, + content: response.message.content, + citationsJSON: try? JSONEncoder().encode(response.citations), + createdAt: Date(), + thread: thread + ) + context.insert(assistantMsg) + thread.updatedAt = Date() + try? context.save() + isSending = false + } catch { + context.delete(userMsg) + try? context.save() + isSending = false + lastError = error.localizedDescription + throw error + } + } +} diff --git a/src/mobile/Muesli/Views/AISummaryEditorView.swift b/src/mobile/Muesli/Views/AISummaryEditorView.swift deleted file mode 100644 index 62bfdf6..0000000 --- a/src/mobile/Muesli/Views/AISummaryEditorView.swift +++ /dev/null @@ -1,342 +0,0 @@ -// -// AISummaryEditorView.swift -// Muesli -// -// AI-powered summary editor for notes -// - -import SwiftUI - -struct AISummaryEditorView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - - let note: Note - @State private var summary = "" - @State private var isGenerating = false - @State private var showingError = false - @State private var errorMessage = "" - - var body: some View { - NavigationView { - VStack(spacing: 20) { - // Header - VStack(alignment: .leading, spacing: 8) { - HStack { - Image(systemName: "brain") - .foregroundColor(.teal) - .font(.title2) - - Text("AI Summary") - .font(.title2) - .fontWeight(.bold) - .foregroundColor(.white) - - Spacer() - } - - Text("Edit or generate an AI-powered summary for '\(note.title)'") - .font(.subheadline) - .foregroundColor(.gray) - .lineLimit(2) - } - .padding(.horizontal, 20) - .padding(.top, 20) - - // Summary Editor - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Summary") - .font(.headline) - .foregroundColor(.white) - - Spacer() - - if isGenerating { - ProgressView() - .scaleEffect(0.8) - .progressViewStyle(CircularProgressViewStyle(tint: .teal)) - } - } - - TextEditor(text: $summary) - .font(.body) - .foregroundColor(.white) - .scrollContentBackground(.hidden) - .background(Color.gray.opacity(0.2)) - .cornerRadius(12) - .frame(minHeight: 200) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(Color.gray.opacity(0.3), lineWidth: 1) - ) - } - .padding(.horizontal, 20) - - // Action Buttons - VStack(spacing: 12) { - Button(action: generateSummary) { - HStack { - Image(systemName: "sparkles") - .font(.system(size: 16)) - Text(isGenerating ? "Generating..." : "Generate AI Summary") - } - .foregroundColor(.white) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(isGenerating ? Color.gray : Color.teal) - .cornerRadius(12) - } - .disabled(isGenerating) - - Button(action: generateKeyPoints) { - HStack { - Image(systemName: "list.bullet") - .font(.system(size: 16)) - Text("Extract Key Points") - } - .foregroundColor(.teal) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.teal.opacity(0.2)) - .cornerRadius(12) - } - .disabled(isGenerating) - } - .padding(.horizontal, 20) - - // Original Content Preview - VStack(alignment: .leading, spacing: 8) { - Text("Original Content") - .font(.headline) - .foregroundColor(.white) - - ScrollView { - Text(note.content.isEmpty ? "No content available" : note.content) - .font(.body) - .foregroundColor(.gray) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - } - .frame(maxHeight: 150) - } - .padding(.horizontal, 20) - - Spacer() - } - .background(Color.black) - .navigationTitle("AI Summary") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - dismiss() - } - .foregroundColor(.white) - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button("Save") { - saveSummary() - } - .foregroundColor(.teal) - .disabled(summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - .preferredColorScheme(.dark) - .alert("Error", isPresented: $showingError) { - Button("OK") { } - } message: { - Text(errorMessage) - } - .onAppear { - loadExistingSummary() - AppLogger.shared.userAction("Open AI Summary Editor", context: note.title) - } - } - - // MARK: - Helper Methods - - private func loadExistingSummary() { - // For now, check if there's existing summary content - // This could be stored as metadata or in a separate field - summary = extractExistingSummary() - } - - private func extractExistingSummary() -> String { - // Look for existing summary markers in the content - let lines = note.content.components(separatedBy: .newlines) - var summaryLines: [String] = [] - var inSummarySection = false - - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.lowercased().contains("summary") || trimmed.lowercased().contains("tldr") { - inSummarySection = true - continue - } else if trimmed.hasPrefix("#") && inSummarySection { - break - } else if inSummarySection && !trimmed.isEmpty { - summaryLines.append(trimmed) - } - } - - return summaryLines.joined(separator: "\n") - } - - private func generateSummary() { - guard !note.content.isEmpty else { - showError("Cannot generate summary for empty note") - return - } - - isGenerating = true - AppLogger.shared.userAction("Generate AI Summary", context: note.title) - - // Simulate AI processing with delay - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.summary = self.generateSimulatedSummary() - self.isGenerating = false - } - } - - private func generateKeyPoints() { - guard !note.content.isEmpty else { - showError("Cannot extract key points from empty note") - return - } - - isGenerating = true - AppLogger.shared.userAction("Extract Key Points", context: note.title) - - // Simulate AI processing with delay - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.summary = self.extractSimulatedKeyPoints() - self.isGenerating = false - } - } - - private func generateSimulatedSummary() -> String { - // Simulate AI-generated summary based on content analysis - let wordCount = note.content.components(separatedBy: .whitespacesAndNewlines).count - let hasHeaders = note.content.contains("#") - let hasBullets = note.content.contains("•") || note.content.contains("○") - - var summaryParts: [String] = [] - - summaryParts.append("📝 **Summary of '\(note.title)'**") - - if wordCount > 100 { - summaryParts.append("This comprehensive note contains \(wordCount) words covering multiple topics.") - } else { - summaryParts.append("This concise note covers key information in \(wordCount) words.") - } - - if hasHeaders { - summaryParts.append("The content is well-organized with clear section headers.") - } - - if hasBullets { - summaryParts.append("Key points are structured using bullet points for easy reference.") - } - - // Add session-specific insights - switch note.sessionType { - case "meeting": - summaryParts.append("**Meeting Insights:** Action items and decisions are clearly outlined.") - case "session": - summaryParts.append("**Session Insights:** Important concepts and takeaways are documented.") - default: - summaryParts.append("**Key Insights:** Essential information is captured for future reference.") - } - - summaryParts.append("**Generated on:** \(Date().formatted(date: .abbreviated, time: .shortened))") - - return summaryParts.joined(separator: "\n\n") - } - - private func extractSimulatedKeyPoints() -> String { - let lines = note.content.components(separatedBy: .newlines) - var keyPoints: [String] = [] - - // Extract headers and bullet points as key points - for line in lines { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("# ") { - keyPoints.append("🎯 " + String(trimmed.dropFirst(2))) - } else if trimmed.hasPrefix("• ") { - keyPoints.append("• " + String(trimmed.dropFirst(2))) - } else if trimmed.hasPrefix("○ ") { - keyPoints.append(" ○ " + String(trimmed.dropFirst(2))) - } - } - - if keyPoints.isEmpty { - // Generate generic key points if no structure found - keyPoints = [ - "📝 Content captured from '\(note.title)'", - "• Session type: \(note.sessionType.capitalized)", - "• Created: \(note.dateString)", - "• Word count: ~\(note.content.components(separatedBy: .whitespacesAndNewlines).count) words" - ] - } - - return "**Key Points:**\n\n" + keyPoints.joined(separator: "\n") - } - - private func saveSummary() { - do { - // For now, we'll prepend the summary to the note content - let summarySection = "# AI Summary\n\n\(summary)\n\n---\n\n" - - // Remove existing summary if present - var cleanContent = note.content - if cleanContent.contains("# AI Summary") { - let components = cleanContent.components(separatedBy: "---") - if components.count > 1 { - cleanContent = components.dropFirst().joined(separator: "---").trimmingCharacters(in: .whitespacesAndNewlines) - } - } - - note.content = summarySection + cleanContent - try modelContext.save() - - AppLogger.shared.userAction("Save AI Summary", context: note.title) - dismiss() - } catch { - showError("Failed to save summary: \(error.localizedDescription)") - } - } - - private func showError(_ message: String) { - errorMessage = message - showingError = true - AppLogger.shared.error("AI Summary Editor Error: \(message)") - } -} - -#Preview { - let sampleNote = Note( - title: "Sample Meeting", - content: """ - # Meeting Overview - - • Discussed project timeline - • Reviewed budget allocations - • Assigned team responsibilities - - # Action Items - - ○ Schedule follow-up meeting - ○ Prepare status report - ○ Contact external vendors - """, - sessionType: "meeting" - ) - - AISummaryEditorView(note: sampleNote) -} \ No newline at end of file diff --git a/src/mobile/Muesli/Views/SimpleArchiveView.swift b/src/mobile/Muesli/Views/ArchiveView.swift similarity index 93% rename from src/mobile/Muesli/Views/SimpleArchiveView.swift rename to src/mobile/Muesli/Views/ArchiveView.swift index f494901..8f7795d 100644 --- a/src/mobile/Muesli/Views/SimpleArchiveView.swift +++ b/src/mobile/Muesli/Views/ArchiveView.swift @@ -1,5 +1,5 @@ // -// SimpleArchiveView.swift +// ArchiveView.swift // Muesli // // Created by Travis Frisinger on 8/25/25. @@ -8,14 +8,14 @@ import SwiftUI import SwiftData -struct SimpleArchiveView: View { +struct ArchiveView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext - @Query(filter: #Predicate { $0.isArchived }, sort: \Note.timestamp, order: .reverse) + @Query(filter: #Predicate { $0.isArchived }, sort: \Note.timestamp, order: .reverse) private var archivedNotes: [Note] - - @State private var selectedNote: Note? = nil - + + @State private var selectedNote: Note? + private var groupedArchivedNotes: [(String, [Note])] { let groups = Dictionary(grouping: archivedNotes) { note in note.dateString @@ -25,7 +25,7 @@ struct SimpleArchiveView: View { first.value.first?.timestamp ?? Date() > second.value.first?.timestamp ?? Date() } } - + var body: some View { NavigationView { Group { @@ -34,12 +34,12 @@ struct SimpleArchiveView: View { Image(systemName: "archivebox") .font(.system(size: 60)) .foregroundColor(.gray) - + Text("No Archived Notes") .font(.title2) .foregroundColor(.gray) .padding(.top, 16) - + Text("Archived notes will appear here") .font(.body) .foregroundColor(.gray.opacity(0.7)) @@ -54,7 +54,7 @@ struct SimpleArchiveView: View { .font(.headline) .foregroundColor(.gray) .padding(.horizontal, 20) - + ForEach(dateGroup.1) { note in SimpleArchivedNoteCard( title: note.title, @@ -91,13 +91,13 @@ struct SimpleArchiveView: View { } } .sheet(item: $selectedNote) { note in - SimpleNoteDetailView(note: note) + NavigationStack { AugmentedNoteView(note: note) } } .preferredColorScheme(.dark) } - + // MARK: - Helper Methods - + private func unarchiveNote(_ note: Note) { do { note.isArchived = false @@ -106,7 +106,7 @@ struct SimpleArchiveView: View { AppLogger.shared.dataError("Unarchive Note", error: error, details: "Title: \(note.title)") } } - + private func deleteNote(_ note: Note) { do { modelContext.delete(note) @@ -123,7 +123,7 @@ struct SimpleArchivedNoteCard: View { let onTap: () -> Void let onUnarchive: () -> Void let onDelete: () -> Void - + var body: some View { Button(action: onTap) { HStack { @@ -133,20 +133,20 @@ struct SimpleArchivedNoteCard: View { .frame(width: 40, height: 40) .background(Color.orange.opacity(0.2)) .cornerRadius(8) - + VStack(alignment: .leading, spacing: 4) { Text(title) .foregroundColor(.white.opacity(0.8)) .font(.system(size: 16, weight: .medium)) .lineLimit(1) - + Text(time) .foregroundColor(.gray) .font(.system(size: 14)) } - + Spacer() - + Text("ARCHIVED") .font(.caption) .foregroundColor(.orange) @@ -169,6 +169,6 @@ struct SimpleArchivedNoteCard: View { } #Preview { - SimpleArchiveView() + ArchiveView() .modelContainer(for: Note.self, inMemory: true) } diff --git a/src/mobile/Muesli/Views/AugmentedNoteView.swift b/src/mobile/Muesli/Views/AugmentedNoteView.swift new file mode 100644 index 0000000..ddddc82 --- /dev/null +++ b/src/mobile/Muesli/Views/AugmentedNoteView.swift @@ -0,0 +1,144 @@ +// +// AugmentedNoteView.swift +// Muesli +// +// Flagship note detail view: renders blendedMarkdown + parallel char-range +// overlays + photo cards as a vertically-scrolling document. +// + +import SwiftUI +import SwiftData + +struct AugmentedNoteView: View { + let note: Note + + @Environment(\.modelContext) private var modelContext + @State private var showingPlayback = false + @State private var playbackStartAt: Double = 0 + @State private var chatThread: ChatThread? + + private var segments: [BlendSegment] { + BlendRenderer.render(note: note) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + header + + if segments.isEmpty { + blendStatusFallback + } else { + ForEach(Array(segments.enumerated()), id: \.offset) { _, seg in + switch seg { + case .text(let attr): + let targets = BlendRenderer.tapTargets(in: attr) + if targets.isEmpty { + Text(attr) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } else { + TappableAttributedText( + attributed: attr, + targets: targets + ) { seconds in + playbackStartAt = seconds + showingPlayback = true + } + .frame(maxWidth: .infinity, alignment: .leading) + } + case .photo(let photo, let caption): + SlideCard(photo: photo, caption: caption) + } + } + } + } + .padding() + } + .navigationTitle(note.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingPlayback = true + } label: { + Label("Listen", systemImage: "play.circle") + } + .disabled(note.audioFilePath == nil) + } + ToolbarItem(placement: .topBarTrailing) { + Button { + openChat() + } label: { + Label("Ask", systemImage: "bubble.left") + } + // Chat addresses the backend session by Note.backendSessionId. + // Before the blend pipeline writes that field the backend has + // no session row, so 404 would be confusing — gate the button. + .disabled(note.backendSessionId == nil) + } + } + .sheet(isPresented: $showingPlayback) { + ChapteredPlaybackView(note: note, startAt: playbackStartAt) + } + .sheet(item: $chatThread) { thread in + ChatView(thread: thread, scopeTitle: "Talk · \(note.title)") + } + } + + private func openChat() { + let noteId = note.id + let talkRaw = ChatScopeKind.talk.rawValue + let predicate = #Predicate { + $0.scopeKindRaw == talkRaw && $0.scopeId == noteId + } + if let existing = try? modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + chatThread = existing + } else { + let thread = ChatThread(scopeKind: .talk, scopeId: note.id) + modelContext.insert(thread) + try? modelContext.save() + chatThread = thread + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + if let conf = note.resolvedConferenceName { + Text(conf) + .font(.caption.weight(.semibold)) + .foregroundColor(.accentColor) + } + if let speaker = note.speaker { + Text("· \(speaker)").font(.caption).foregroundColor(.secondary) + } + Text("· \(note.dateString)").font(.caption).foregroundColor(.secondary) + } + Text(note.title) + .font(.title2.weight(.bold)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var blendStatusFallback: some View { + switch note.blendStatus { + case .idle, .transcribing, .transcribed, .extracting, .blending: + BlendingOverlay(status: note.blendStatus) + case .failed: + BlendingOverlay(status: .failed, error: note.blendError) + case .complete: + // Inconsistent state: pipeline reported complete but no markdown + // landed. Surface as an error rather than silently substituting + // raw transcript, which would hide the corruption. + BlendingOverlay( + status: .failed, + error: "Blend output is missing. Try blending again." + ) + .onAppear { + AppLogger.shared.error("AugmentedNoteView: note \(note.id) has blendStatus .complete but blendedMarkdown is nil") + } + } + } +} diff --git a/src/mobile/Muesli/Views/ChapteredPlaybackController.swift b/src/mobile/Muesli/Views/ChapteredPlaybackController.swift new file mode 100644 index 0000000..05cdb11 --- /dev/null +++ b/src/mobile/Muesli/Views/ChapteredPlaybackController.swift @@ -0,0 +1,103 @@ +// +// ChapteredPlaybackController.swift +// Muesli +// +// @Observable wrapper around AVAudioPlayer. Publishes currentTime / +// isPlaying / duration so the view binds against player state. +// + +import Foundation +import AVFoundation + +@MainActor +@Observable +final class ChapteredPlaybackController { + private(set) var currentTime: Double = 0 + private(set) var duration: Double = 0 + private(set) var isPlaying: Bool = false + var loadError: String? + + private var player: AVAudioPlayer? + // `Timer.invalidate()` is safe from any thread; the property is touched + // from deinit (nonisolated) so it carries the unsafe annotation. + nonisolated(unsafe) private var timer: Timer? + + func load(audioFileURL url: URL) { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) + try AVAudioSession.sharedInstance().setActive(true) + let p = try AVAudioPlayer(contentsOf: url) + p.prepareToPlay() + self.player = p + self.duration = p.duration + self.currentTime = 0 + self.loadError = nil + } catch { + self.loadError = error.localizedDescription + AppLogger.shared.error("ChapteredPlaybackController: failed to load \(url.lastPathComponent)", error: error) + } + } + + func play() { + guard let player else { return } + player.play() + isPlaying = true + startTimer() + } + + func pause() { + player?.pause() + isPlaying = false + stopTimer() + } + + func toggle() { + isPlaying ? pause() : play() + } + + /// Seeks the player. AVAudioPlayer.currentTime can be sticky for a frame + /// or two when set while playing on some iOS versions; if the user reports + /// hearing the prior position briefly, switch to a pause/seek/play cycle. + func seek(to seconds: Double) { + guard let player else { return } + let clamped = max(0, min(seconds, duration)) + player.currentTime = clamped + currentTime = clamped + } + + /// Skips by `offset` chapters and resumes playback. Matches the + /// chapter-list row's tap behavior so the user gets consistent + /// playback-on-jump across all chapter-navigation entry points. + func skipChapter(offset: Int, chapters: [ChapterModel]) { + let current = PlaybackTimer.currentChapterIndex(at: currentTime, chapters: chapters) + let target = max(0, min(current + offset, chapters.count - 1)) + guard chapters.indices.contains(target) else { return } + seek(to: chapters[target].start) + play() + } + + deinit { + timer?.invalidate() + } + + private func startTimer() { + stopTimer() + // The runloop fires on main; trust the isolation rather than spawning + // a fresh Task four times per second. + timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let player = self.player else { return } + self.currentTime = player.currentTime + if !player.isPlaying && self.isPlaying { + self.isPlaying = false + self.stopTimer() + } + } + } + } + + private func stopTimer() { + timer?.invalidate() + timer = nil + } +} diff --git a/src/mobile/Muesli/Views/ChapteredPlaybackView.swift b/src/mobile/Muesli/Views/ChapteredPlaybackView.swift new file mode 100644 index 0000000..af91754 --- /dev/null +++ b/src/mobile/Muesli/Views/ChapteredPlaybackView.swift @@ -0,0 +1,171 @@ +// +// ChapteredPlaybackView.swift +// Muesli +// +// Full-screen sheet: now-playing header + chapter scrubber + transport +// controls + tappable chapter list. Audio comes from note.audioFilePath +// via AudioRecordingManager. +// + +import SwiftUI + +struct ChapteredPlaybackView: View { + let note: Note + var startAt: Double = 0 + + @State private var controller = ChapteredPlaybackController() + @State private var chapters: [ChapterModel] = [] + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + header + .padding(.horizontal) + .padding(.top, 12) + + if let err = controller.loadError { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundColor(.orange) + Text(err).font(.footnote).multilineTextAlignment(.center) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + scrubberRow + .padding(.horizontal) + .padding(.top, 8) + transport + .padding(.top, 16) + + chapterList + } + + Spacer(minLength: 0) + } + .navigationTitle("Now playing") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .onAppear { setup() } + .onDisappear { controller.pause() } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Chapter \(currentChapterDisplayIndex)") + .font(.caption.weight(.semibold)) + .foregroundColor(.accentColor) + Text(note.title) + .font(.title3.weight(.semibold)) + if let speaker = note.speaker, !speaker.isEmpty { + Text(speaker).font(.subheadline).foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var scrubberRow: some View { + VStack(spacing: 6) { + ChapterScrubber( + duration: controller.duration, + chapters: chapters, + currentTime: controller.currentTime, + onSeek: { controller.seek(to: $0) } + ) + HStack { + Text(PlaybackTimer.formatTime(controller.currentTime)) + Spacer() + Text(PlaybackTimer.formatTime(controller.duration)) + } + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + } + + private var transport: some View { + HStack(spacing: 32) { + Button { + controller.skipChapter(offset: -1, chapters: chapters) + } label: { + Image(systemName: "backward.end.fill").font(.title2) + } + .disabled(chapters.isEmpty) + + Button { + controller.toggle() + } label: { + Image(systemName: controller.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 56)) + } + + Button { + controller.skipChapter(offset: 1, chapters: chapters) + } label: { + Image(systemName: "forward.end.fill").font(.title2) + } + .disabled(chapters.isEmpty) + } + } + + private var chapterList: some View { + List { + ForEach(chapters) { chapter in + Button { + controller.seek(to: chapter.start) + controller.play() + } label: { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(chapter.title) + .font(.body.weight(.semibold)) + .foregroundColor(.primary) + if !chapter.summary.isEmpty { + Text(chapter.summary) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + Text(PlaybackTimer.formatTime(chapter.start)) + .font(.caption.monospacedDigit()) + .foregroundColor(.secondary) + } + } + } + } + .listStyle(.plain) + .padding(.top, 16) + } + + private var currentChapterDisplayIndex: String { + guard !chapters.isEmpty else { return "—" } + let i = PlaybackTimer.currentChapterIndex(at: controller.currentTime, chapters: chapters) + return String(format: "%02d", i + 1) + } + + private func setup() { + chapters = PlaybackTimer.decodeChapters(from: note.chaptersJSON) + guard let path = note.audioFilePath else { + controller.loadError = "Audio file not found." + return + } + // Mirror SlideCard: callers may pass absolute paths or relative + // filenames; resolve both into a URL. + let url: URL? = path.hasPrefix("/") + ? URL(fileURLWithPath: path) + : AudioRecordingManager.shared.getRecordingURL(fileName: path) + guard let url else { + controller.loadError = "Audio file not found." + return + } + controller.load(audioFileURL: url) + if startAt > 0 { controller.seek(to: startAt) } + } +} diff --git a/src/mobile/Muesli/Views/ChatView.swift b/src/mobile/Muesli/Views/ChatView.swift new file mode 100644 index 0000000..13593fc --- /dev/null +++ b/src/mobile/Muesli/Views/ChatView.swift @@ -0,0 +1,169 @@ +// +// ChatView.swift +// Muesli +// +// Chat sheet for a talk or a conference. Persists messages to SwiftData +// via ChatViewModel; sends turns through World.current.chat. +// + +import SwiftUI +import SwiftData + +struct ChatView: View { + let thread: ChatThread + /// Display title for the scope chip (e.g., "DataSummit 2026 · 12 talks" + /// or "Talk · The three pillars"). + let scopeTitle: String + + @Environment(\.modelContext) private var modelContext + @Environment(\.dismiss) private var dismiss + @State private var viewModel: ChatViewModel? + @State private var draft: String = "" + @State private var playbackTarget: PlaybackTarget? + @State private var noteTarget: Note? + + /// Identifiable wrapper so `.sheet(item:)` can present the chaptered + /// playback view at a specific timestamp. + struct PlaybackTarget: Identifiable { + let id = UUID() + let note: Note + let startSec: Double + } + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + scopeChip + .padding(.horizontal) + .padding(.top, 8) + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(viewModel?.messagesSorted ?? []) { message in + bubble(for: message) + .id(message.id) + } + if let err = viewModel?.lastError { + Text(err) + .font(.caption) + .foregroundColor(.red) + .padding(.horizontal) + } + } + .padding() + } + .onChange(of: viewModel?.messagesSorted.last?.id) { _, newValue in + if let id = newValue { + withAnimation { proxy.scrollTo(id, anchor: .bottom) } + } + } + } + + inputRow + } + .navigationTitle("Chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + .onAppear { + if viewModel == nil { + viewModel = ChatViewModel(thread: thread, chat: World.current.chat, context: modelContext) + } + } + .sheet(item: $playbackTarget) { target in + ChapteredPlaybackView(note: target.note, startAt: target.startSec) + } + .sheet(item: $noteTarget) { note in + NavigationStack { AugmentedNoteView(note: note) } + } + } + + private func openCitation(_ citation: ChatCitation) { + switch citation.kind { + case .transcript: + guard let talkId = citation.talkId else { return } + let predicate = #Predicate { $0.id == talkId || $0.backendSessionId == talkId } + if let note = try? modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + playbackTarget = PlaybackTarget(note: note, startSec: citation.startSec ?? 0) + } + case .note: + guard let noteId = citation.noteId else { return } + let predicate = #Predicate { $0.id == noteId || $0.backendSessionId == noteId } + if let note = try? modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + noteTarget = note + } + } + } + + private var scopeChip: some View { + HStack(spacing: 6) { + Image(systemName: thread.scopeKind == .talk ? "doc.text" : "calendar") + .font(.caption) + Text(scopeTitle) + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.accentColor.opacity(0.12)) + .foregroundColor(.accentColor) + .clipShape(Capsule()) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func bubble(for message: ChatMessage) -> some View { + let isUser = (message.role == .user) + let citations: [ChatCitation] = (message.citationsJSON).flatMap { + try? JSONDecoder().decode([ChatCitation].self, from: $0) + } ?? [] + return HStack { + if isUser { Spacer(minLength: 32) } + VStack(alignment: .leading, spacing: 6) { + Text(message.content) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isUser ? Color.accentColor : Color.gray.opacity(0.15)) + .foregroundColor(isUser ? .white : .primary) + .clipShape(RoundedRectangle(cornerRadius: 14)) + if !citations.isEmpty { + HStack(spacing: 6) { + ForEach(Array(citations.enumerated()), id: \.offset) { _, c in + CitationChip(citation: c) { openCitation(c) } + } + } + } + } + if !isUser { Spacer(minLength: 32) } + } + } + + private var inputRow: some View { + HStack(spacing: 8) { + TextField("Ask a question…", text: $draft, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + Button { + Task { await submit() } + } label: { + Image(systemName: "arrow.up.circle.fill").font(.title2) + } + .disabled(viewModel?.isSending ?? false || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding() + } + + private func submit() async { + guard let viewModel else { return } + let content = draft + draft = "" + do { + try await viewModel.send(content: content) + } catch { + // ChatViewModel already records lastError; nothing else to do. + } + } +} diff --git a/src/mobile/Muesli/Views/Components/BlendRenderer.swift b/src/mobile/Muesli/Views/Components/BlendRenderer.swift new file mode 100644 index 0000000..3bd4c9f --- /dev/null +++ b/src/mobile/Muesli/Views/Components/BlendRenderer.swift @@ -0,0 +1,231 @@ +// +// BlendRenderer.swift +// Muesli +// +// Pure value-type renderer that converts a Note's blend pipeline output +// (blendedMarkdown + parallel char-range arrays + photos) into a list of +// display segments. The view layer iterates the segments and draws each. +// + +import Foundation +import SwiftUI + +/// A single segment of the augmented-note display. +enum BlendSegment { + case text(AttributedString) + /// A full-width photo card with an optional caption from blend output. + case photo(Photo, caption: String?) +} + +enum BlendRenderer { + /// Returns the list of display segments for a Note. Empty if the note has + /// no `blendedMarkdown`. Defensive against bad span offsets (clamped + skipped). + /// + /// Char offsets in BlendCitations are UTF-16 code-unit offsets into the raw + /// `blendedMarkdown` source — that's what the Node blend service produces + /// (Sonnet sees the same string-length semantics, `String.length` in JS). + /// Swift's grapheme-cluster indices would drift on non-ASCII content, so + /// translation goes through `String.UTF16View` → `String.Index` → + /// `AttributedString.Index`. + static func render(note: Note) -> [BlendSegment] { + guard let markdown = note.blendedMarkdown, !markdown.isEmpty else { return [] } + + let citations: BlendCitations = (note.blendCitationsJSON.flatMap { + try? JSONDecoder().decode(BlendCitations.self, from: $0) + }) ?? BlendCitations(userNoteSpans: [], quoteSpans: [], imagePlacements: [], citations: []) + + // We render the raw markdown text as AttributedString without parsing. + // The blend service emits char offsets into the raw source; any markdown + // parser would shift indices and break the overlays. + var base = AttributedString(markdown) + + applyUserNoteSpans(citations.userNoteSpans, source: markdown, on: &base) + applyQuoteSpans(citations.quoteSpans, source: markdown, on: &base) + applyCitations(citations.citations, source: markdown, on: &base) + + return splitAtImagePlacements( + base: base, + source: markdown, + placements: citations.imagePlacements, + photos: note.photos + ) + } + + // MARK: - Overlays + + private static func applyUserNoteSpans(_ spans: [UserNoteSpan], source: String, on attr: inout AttributedString) { + for span in spans { + guard let range = range(in: attr, source: source, start: span.start, end: span.end) else { + AppLogger.shared.warning("BlendRenderer: dropping userNoteSpan with bad range \(span.start)..<\(span.end)") + continue + } + attr[range].inlinePresentationIntent = .stronglyEmphasized + attr[range].foregroundColor = .accentColor + } + } + + private static func applyQuoteSpans(_ spans: [QuoteSpan], source: String, on attr: inout AttributedString) { + for span in spans { + guard let range = range(in: attr, source: source, start: span.start, end: span.end) else { + AppLogger.shared.warning("BlendRenderer: dropping quoteSpan with bad range \(span.start)..<\(span.end)") + continue + } + attr[range].inlinePresentationIntent = .emphasized + attr[range].quoteStartSec = span.transcriptStart + attr[range].quoteEndSec = span.transcriptEnd + } + } + + private static func applyCitations(_ cites: [Citation], source: String, on attr: inout AttributedString) { + for c in cites { + guard let range = range(in: attr, source: source, start: c.blendStart, end: c.blendEnd) else { + AppLogger.shared.warning("BlendRenderer: dropping citation with bad range \(c.blendStart)..<\(c.blendEnd)") + continue + } + attr[range].underlineStyle = .single + attr[range].citationTranscriptStart = c.transcriptStart + attr[range].citationTranscriptEnd = c.transcriptEnd + } + } + + // MARK: - Image placement splitting + + private static func splitAtImagePlacements( + base: AttributedString, + source: String, + placements: [ImagePlacement], + photos: [Photo] + ) -> [BlendSegment] { + let utf16Count = source.utf16.count + let photoById = Dictionary(uniqueKeysWithValues: photos.map { ($0.id.uuidString, $0) }) + + var validPlacements: [ImagePlacement] = [] + for p in placements { + if p.charOffset < 0 || p.charOffset > utf16Count { + AppLogger.shared.warning("BlendRenderer: dropping placement at offset \(p.charOffset) (markdown is \(utf16Count) UTF-16 units)") + continue + } + if photoById[p.imageId] == nil { + AppLogger.shared.warning("BlendRenderer: dropping placement for unknown photoId \(p.imageId)") + continue + } + validPlacements.append(p) + } + validPlacements.sort { $0.charOffset < $1.charOffset } + + if validPlacements.isEmpty { + return [.text(base)] + } + + var segments: [BlendSegment] = [] + var cursor = 0 + for p in validPlacements { + if p.charOffset > cursor, + let lo = attributedIndex(in: base, source: source, utf16Offset: cursor), + let hi = attributedIndex(in: base, source: source, utf16Offset: p.charOffset) { + segments.append(.text(AttributedString(base[lo.. [TappableTextTarget] { + var targets: [TappableTextTarget] = [] + var nsLocation = 0 + for run in attr.runs { + let runText = String(attr[run.range].characters) + let utf16Length = runText.utf16.count + let startSec: Double? + if let q = run.quoteStartSec { + startSec = q + } else if let c = run.citationTranscriptStart { + startSec = c + } else { + startSec = nil + } + if let target = startSec, utf16Length > 0 { + targets.append(TappableTextTarget( + range: NSRange(location: nsLocation, length: utf16Length), + startSec: target + )) + } + nsLocation += utf16Length + } + return targets + } + + // MARK: - Index helpers + + /// Translates a UTF-16 code-unit offset into the source string into an + /// `AttributedString.Index` on the parallel attributed string. Returns + /// nil if the offset lands inside a surrogate pair or beyond the end. + private static func attributedIndex(in attr: AttributedString, source: String, utf16Offset: Int) -> AttributedString.Index? { + let utf16Count = source.utf16.count + let clamped = max(0, min(utf16Offset, utf16Count)) + let stringIdx = String.Index(utf16Offset: clamped, in: source) + return AttributedString.Index(stringIdx, within: attr) + } + + private static func range(in attr: AttributedString, source: String, start: Int, end: Int) -> Range? { + let utf16Count = source.utf16.count + let lo = max(0, min(start, utf16Count)) + let hi = max(lo, min(end, utf16Count)) + guard lo < hi, + let from = attributedIndex(in: attr, source: source, utf16Offset: lo), + let to = attributedIndex(in: attr, source: source, utf16Offset: hi) + else { return nil } + return from..(dynamicMember keyPath: KeyPath) -> T { + self[T.self] + } +} diff --git a/src/mobile/Muesli/Views/Components/BlendingOverlay.swift b/src/mobile/Muesli/Views/Components/BlendingOverlay.swift new file mode 100644 index 0000000..71895d9 --- /dev/null +++ b/src/mobile/Muesli/Views/Components/BlendingOverlay.swift @@ -0,0 +1,85 @@ +// +// BlendingOverlay.swift +// Muesli +// +// Visual representation of a Note's blend pipeline state. Hosts pick +// between the inline (vertical-stack) presentation and a full-screen +// modal-friendly variant via the `style` parameter. Spec Scene v. +// + +import SwiftUI + +struct BlendingOverlay: View { + let status: BlendStatus + var error: String? + var style: Style = .inline + + enum Style { + /// Stacked vertically; suitable for embedding inside ScrollView. + case inline + /// Centered with extra vertical padding; suitable for sheet overlay. + case fullScreen + } + + var body: some View { + VStack(spacing: 12) { + switch status { + case .idle: + indicator(systemImage: "clock", label: "Waiting to start…") + case .transcribing, .transcribed: + spinner(label: "Transcribing audio…") + case .extracting: + spinner(label: "Extracting slide text…") + case .blending: + spinner(label: "Blending notes with AI…") + case .complete: + indicator(systemImage: "checkmark.circle.fill", + label: "Done.", + tint: .green) + case .failed: + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.title) + .foregroundColor(.orange) + Text(error ?? "Blend failed.") + .font(.footnote) + .multilineTextAlignment(.center) + } + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, style == .fullScreen ? 48 : 24) + } + + private func spinner(label: String) -> some View { + VStack(spacing: 8) { + ProgressView() + .controlSize(style == .fullScreen ? .large : .regular) + Text(label) + .font(.footnote) + .foregroundColor(.secondary) + } + } + + private func indicator(systemImage: String, label: String, tint: Color = .secondary) -> some View { + VStack(spacing: 8) { + Image(systemName: systemImage) + .font(.title) + .foregroundColor(tint) + Text(label) + .font(.footnote) + .foregroundColor(.secondary) + } + } +} + +#Preview { + VStack(spacing: 24) { + BlendingOverlay(status: .transcribing) + Divider() + BlendingOverlay(status: .blending) + Divider() + BlendingOverlay(status: .failed, error: "Sonnet returned 502.") + } + .padding() +} diff --git a/src/mobile/Muesli/Views/Components/ChapterScrubber.swift b/src/mobile/Muesli/Views/Components/ChapterScrubber.swift new file mode 100644 index 0000000..e55f691 --- /dev/null +++ b/src/mobile/Muesli/Views/Components/ChapterScrubber.swift @@ -0,0 +1,75 @@ +// +// ChapterScrubber.swift +// Muesli +// +// Horizontal track with chapter-boundary ticks and a draggable thumb. +// Reports drag through a binding; the host commits via `seek(to:)`. +// + +import SwiftUI + +struct ChapterScrubber: View { + let duration: Double + let chapters: [ChapterModel] + let currentTime: Double + /// Invoked with the target time during drag and on commit. The host + /// chooses whether each call should be a seek (which it does for both + /// taps and drag updates so the playhead tracks the finger). + let onSeek: (Double) -> Void + + /// When non-nil, the scrubber is being dragged; render this value as + /// the thumb position instead of `currentTime` so the bar tracks the + /// finger even if the controller's timer overwrites currentTime in + /// the same frame. + @State private var dragValue: Double? + + var body: some View { + GeometryReader { geo in + let displayTime = dragValue ?? currentTime + ZStack(alignment: .leading) { + Capsule() + .fill(Color.gray.opacity(0.2)) + .frame(height: 6) + + Capsule() + .fill(Color.accentColor) + .frame(width: progressWidth(for: displayTime, in: geo.size.width), height: 6) + + ForEach(chapters) { chapter in + let x = progressWidth(for: chapter.start, in: geo.size.width) + Rectangle() + .fill(Color.primary.opacity(0.4)) + .frame(width: 2, height: 12) + .offset(x: x - 1) + } + + Circle() + .fill(Color.accentColor) + .frame(width: 18, height: 18) + .shadow(radius: 2) + .offset(x: progressWidth(for: displayTime, in: geo.size.width) - 9) + } + .frame(height: 18) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + let pct = max(0, min(value.location.x / geo.size.width, 1)) + let t = pct * max(1, duration) + dragValue = t + onSeek(t) + } + .onEnded { _ in + dragValue = nil + } + ) + } + .frame(height: 18) + } + + private func progressWidth(for time: Double, in total: CGFloat) -> CGFloat { + guard duration > 0 else { return 0 } + let pct = time / duration + return CGFloat(max(0, min(pct, 1))) * total + } +} diff --git a/src/mobile/Muesli/Views/Components/CitationChip.swift b/src/mobile/Muesli/Views/Components/CitationChip.swift new file mode 100644 index 0000000..214f238 --- /dev/null +++ b/src/mobile/Muesli/Views/Components/CitationChip.swift @@ -0,0 +1,45 @@ +// +// CitationChip.swift +// Muesli +// +// Pill-shaped citation reference attached below an assistant message. +// Transcript citations show mm:ss; note citations show the note title. +// + +import SwiftUI + +struct CitationChip: View { + let citation: ChatCitation + var onTap: () -> Void = {} + + var body: some View { + Button(action: onTap) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.caption2) + Text(label) + .font(.caption.weight(.medium)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.accentColor.opacity(0.12)) + .foregroundColor(.accentColor) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private var icon: String { + switch citation.kind { + case .transcript: return "clock" + case .note: return "doc.text" + } + } + + private var label: String { + switch citation.kind { + case .transcript: return citation.label ?? "Transcript" + case .note: return citation.title ?? "Note" + } + } +} diff --git a/src/mobile/Muesli/Views/Components/FloatingActionButton.swift b/src/mobile/Muesli/Views/Components/FloatingActionButton.swift index 0166895..4bd73f3 100644 --- a/src/mobile/Muesli/Views/Components/FloatingActionButton.swift +++ b/src/mobile/Muesli/Views/Components/FloatingActionButton.swift @@ -11,7 +11,7 @@ struct FloatingActionButton: View { let action: () -> Void let systemImage: String let backgroundColor: Color - + init( action: @escaping () -> Void, systemImage: String = "plus", @@ -21,7 +21,7 @@ struct FloatingActionButton: View { self.systemImage = systemImage self.backgroundColor = backgroundColor } - + var body: some View { VStack { Spacer() @@ -47,4 +47,4 @@ struct FloatingActionButton: View { #Preview { FloatingActionButton(action: {}) .background(Color.black) -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Views/Components/ImagePicker.swift b/src/mobile/Muesli/Views/Components/ImagePicker.swift index a7fbf09..108c919 100644 --- a/src/mobile/Muesli/Views/Components/ImagePicker.swift +++ b/src/mobile/Muesli/Views/Components/ImagePicker.swift @@ -11,11 +11,11 @@ import UIKit struct ImagePicker: UIViewControllerRepresentable { @Binding var isPresented: Bool let onImagePicked: (UIImage) -> Void - + func makeUIViewController(context: Context) -> UIImagePickerController { let picker = UIImagePickerController() picker.delegate = context.coordinator - + // Check if camera is available (will be false in simulator) if UIImagePickerController.isSourceTypeAvailable(.camera) { picker.sourceType = .camera @@ -23,33 +23,33 @@ struct ImagePicker: UIViewControllerRepresentable { // Fallback to photo library for simulator picker.sourceType = .photoLibrary } - + picker.allowsEditing = false return picker } - + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) { // Nothing to update } - + func makeCoordinator() -> Coordinator { Coordinator(self) } - + class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate { let parent: ImagePicker - + init(_ parent: ImagePicker) { self.parent = parent } - - func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { + + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let image = info[.originalImage] as? UIImage { parent.onImagePicked(image) } parent.isPresented = false } - + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { parent.isPresented = false } diff --git a/src/mobile/Muesli/Views/Components/MainHeaderView.swift b/src/mobile/Muesli/Views/Components/MainHeaderView.swift index 92bc91c..fba1191 100644 --- a/src/mobile/Muesli/Views/Components/MainHeaderView.swift +++ b/src/mobile/Muesli/Views/Components/MainHeaderView.swift @@ -9,16 +9,16 @@ import SwiftUI struct MainHeaderView: View { let onSettingsTap: () -> Void - + var body: some View { HStack { Text("My Notes") .font(.largeTitle) .fontWeight(.bold) .foregroundColor(.white) - + Spacer() - + Button(action: onSettingsTap) { Image(systemName: "gearshape.fill") .font(.title2) @@ -34,4 +34,4 @@ struct MainHeaderView: View { MainHeaderView(onSettingsTap: {}) .background(Color.black) .preferredColorScheme(.dark) -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Views/Components/NoteContentView.swift b/src/mobile/Muesli/Views/Components/NoteContentView.swift index c39b3f4..d9cc962 100644 --- a/src/mobile/Muesli/Views/Components/NoteContentView.swift +++ b/src/mobile/Muesli/Views/Components/NoteContentView.swift @@ -9,13 +9,13 @@ import SwiftUI struct NoteContentView: View { let content: String - + var body: some View { ForEach(parseSimpleContent(content), id: \.text) { item in SimpleContentItemView(item: item) } } - + private func parseSimpleContent(_ content: String) -> [SimpleContentData] { content.components(separatedBy: .newlines) .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } @@ -36,7 +36,7 @@ struct NoteContentView: View { struct SimpleContentItemView: View { let item: SimpleContentData - + var body: some View { HStack(alignment: .top, spacing: 8) { switch item.type { @@ -77,4 +77,4 @@ struct SimpleContentData { enum SimpleContentType { case header, bullet, subBullet, text -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Views/Components/NoteOptionsPopover.swift b/src/mobile/Muesli/Views/Components/NoteOptionsPopover.swift index f3a1944..68063d4 100644 --- a/src/mobile/Muesli/Views/Components/NoteOptionsPopover.swift +++ b/src/mobile/Muesli/Views/Components/NoteOptionsPopover.swift @@ -17,7 +17,7 @@ struct NoteOptionsPopover: View { let onArchive: () -> Void let onDelete: () -> Void let onClose: () -> Void - + var body: some View { VStack(spacing: 0) { NoteOptionRow( @@ -29,7 +29,7 @@ struct NoteOptionsPopover: View { onEditTitle() } } - + Divider().background(Color.gray.opacity(0.5)) NoteOptionRow( @@ -112,7 +112,7 @@ struct NoteOptionRow: View { let icon: String let title: String let action: () -> Void - + var body: some View { Button(action: action) { HStack(spacing: 12) { @@ -120,7 +120,7 @@ struct NoteOptionRow: View { .foregroundColor(.white) .font(.system(size: 16)) .frame(width: 20, height: 20) - + Text(title) .foregroundColor(.white) .font(.system(size: 15)) @@ -131,4 +131,4 @@ struct NoteOptionRow: View { } .buttonStyle(PlainButtonStyle()) } -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Views/Components/NoteRow.swift b/src/mobile/Muesli/Views/Components/NoteRow.swift new file mode 100644 index 0000000..d847fba --- /dev/null +++ b/src/mobile/Muesli/Views/Components/NoteRow.swift @@ -0,0 +1,63 @@ +// +// NoteRow.swift +// Muesli +// +// Notes-list row: title + (conference · speaker · relative date · slide count). +// + +import SwiftUI + +struct NoteRow: View { + let note: Note + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(note.title) + .font(.body.weight(.semibold)) + .lineLimit(2) + if isBlending { + ProgressView() + .controlSize(.small) + .accessibilityLabel("Blending in progress") + } + } + HStack(spacing: 4) { + if let conf = note.resolvedConferenceName { + Text(conf).font(.caption.weight(.semibold)).foregroundColor(.accentColor) + dot + } + if let speaker = note.speaker, !speaker.isEmpty { + Text(speaker).font(.caption).foregroundColor(.secondary) + dot + } + Text(relativeDate(note.timestamp)).font(.caption).foregroundColor(.secondary) + // Use max of the SwiftData photos count and the legacy + // imagePaths array — older notes may have only the latter. + let slideCount = max(note.photos.count, note.imagePaths.count) + if slideCount > 0 { + dot + Text("\(slideCount) slides").font(.caption).foregroundColor(.secondary) + } + } + } + .padding(.vertical, 4) + } + + private var isBlending: Bool { + switch note.blendStatus { + case .transcribing, .transcribed, .extracting, .blending: return true + case .idle, .complete, .failed: return false + } + } + + private var dot: some View { + Text("·").font(.caption).foregroundColor(.secondary) + } + + private func relativeDate(_ date: Date) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return f.localizedString(for: date, relativeTo: Date()) + } +} diff --git a/src/mobile/Muesli/Views/Components/NotesListView.swift b/src/mobile/Muesli/Views/Components/NotesListView.swift deleted file mode 100644 index 483d58b..0000000 --- a/src/mobile/Muesli/Views/Components/NotesListView.swift +++ /dev/null @@ -1,98 +0,0 @@ -// -// NotesListView.swift -// Muesli -// -// Notes list component with grouped sections -// - -import SwiftUI -import SwiftData - -struct NotesListView: View { - let notes: [Note] - let onNoteTap: (Note) -> Void - let onNoteEdit: (Note) -> Void - let onNoteArchive: (Note) -> Void - let onProcessTranscription: ((Note) -> Void)? - - private var groupedNotes: [(String, [Note])] { - let formatter = DateFormatter() - formatter.dateFormat = "EEE d MMM" - - let groups = Dictionary(grouping: notes) { note in - formatter.string(from: note.timestamp) - } - - return groups.sorted { first, second in - // Sort by date, newest first - first.value.first?.timestamp ?? Date() > second.value.first?.timestamp ?? Date() - } - } - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(groupedNotes, id: \.0) { dateGroup in - // Date header - DateHeaderView(dateString: dateGroup.0) - - // Notes for this date - ForEach(dateGroup.1, id: \.id) { note in - SimpleNoteCard( - note: note, - onTap: { - onNoteTap(note) - }, - onEdit: { - onNoteEdit(note) - }, - onArchive: { - onNoteArchive(note) - }, - onProcessTranscription: note.needsTranscription ? { - onProcessTranscription?(note) - } : nil - ) - .padding(.horizontal, 20) - .padding(.bottom, 12) - } - } - } - .padding(.bottom, 120) - } - } -} - -struct DateHeaderView: View { - let dateString: String - - var body: some View { - HStack { - Text(dateString) - .font(.headline) - .fontWeight(.semibold) - .foregroundColor(.white) - Spacer() - } - .padding(.horizontal, 20) - .padding(.top, 30) - .padding(.bottom, 15) - } -} - -#Preview { - let sampleNotes = [ - Note(title: "Sample Note 1", content: "Content 1", timestamp: Date(), sessionType: "note"), - Note(title: "Sample Note 2", content: "Content 2", timestamp: Date().addingTimeInterval(-3600), sessionType: "note") - ] - - NotesListView( - notes: sampleNotes, - onNoteTap: { _ in }, - onNoteEdit: { _ in }, - onNoteArchive: { _ in }, - onProcessTranscription: { _ in } - ) - .background(Color.black) - .preferredColorScheme(.dark) -} \ No newline at end of file diff --git a/src/mobile/Muesli/Views/Components/PlaybackTimer.swift b/src/mobile/Muesli/Views/Components/PlaybackTimer.swift new file mode 100644 index 0000000..6a900e9 --- /dev/null +++ b/src/mobile/Muesli/Views/Components/PlaybackTimer.swift @@ -0,0 +1,59 @@ +// +// PlaybackTimer.swift +// Muesli +// +// Pure helpers used by the chaptered playback view: decode chapters, +// pick the current chapter for a playback time, and format times. +// + +import Foundation + +struct ChapterModel: Equatable, Identifiable { + var id: Int + var start: Double + var title: String + var summary: String + + init(id: Int = 0, start: Double, title: String, summary: String) { + self.id = id + self.start = start + self.title = title + self.summary = summary + } +} + +enum PlaybackTimer { + /// Decode chapters from the JSON shape `BlendOrchestrator` persists to + /// `note.chaptersJSON`. Empty list on missing or malformed input. + static func decodeChapters(from data: Data?) -> [ChapterModel] { + guard let data else { return [] } + guard let wrapper = try? JSONDecoder().decode(ChaptersWrapper.self, from: data) else { return [] } + return wrapper.chapters.enumerated().map { idx, dto in + ChapterModel(id: idx, start: dto.start, title: dto.title, summary: dto.summary ?? "") + } + } + + /// Returns the index of the chapter whose `start <= time`, picking the last + /// satisfying. Returns 0 for empty chapter lists or times before the first + /// chapter starts. + static func currentChapterIndex(at time: Double, chapters: [ChapterModel]) -> Int { + guard !chapters.isEmpty else { return 0 } + var index = 0 + for (i, chapter) in chapters.enumerated() where chapter.start <= time { + index = i + } + return index + } + + /// mm:ss under one hour, h:mm:ss at one hour and over. + static func formatTime(_ seconds: Double) -> String { + let total = max(0, Int(seconds.rounded(.toNearestOrEven))) + let h = total / 3_600 + let m = (total % 3_600) / 60 + let s = total % 60 + if h > 0 { + return String(format: "%d:%02d:%02d", h, m, s) + } + return String(format: "%02d:%02d", m, s) + } +} diff --git a/src/mobile/Muesli/Views/Components/SearchBarView.swift b/src/mobile/Muesli/Views/Components/SearchBarView.swift index f41950b..7c41881 100644 --- a/src/mobile/Muesli/Views/Components/SearchBarView.swift +++ b/src/mobile/Muesli/Views/Components/SearchBarView.swift @@ -10,12 +10,12 @@ import SwiftUI struct SearchBarView: View { @Binding var searchText: String let onSearchTextChange: (String) -> Void - + var body: some View { HStack { Image(systemName: "magnifyingglass") .foregroundColor(.gray) - + TextField("Search", text: $searchText) .foregroundColor(.white) .font(.system(size: 16)) @@ -36,4 +36,4 @@ struct SearchBarView: View { SearchBarView(searchText: .constant("")) { _ in } .background(Color.black) .preferredColorScheme(.dark) -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Views/Components/SlideCard.swift b/src/mobile/Muesli/Views/Components/SlideCard.swift new file mode 100644 index 0000000..1564dde --- /dev/null +++ b/src/mobile/Muesli/Views/Components/SlideCard.swift @@ -0,0 +1,55 @@ +// +// SlideCard.swift +// Muesli +// +// Full-width photo card used between text segments in AugmentedNoteView. +// + +import SwiftUI + +struct SlideCard: View { + let photo: Photo + let caption: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if let uiImage = loadImage() { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + RoundedRectangle(cornerRadius: 12) + .fill(Color.gray.opacity(0.15)) + .frame(height: 180) + .overlay( + Image(systemName: "photo") + .font(.title) + .foregroundColor(.secondary) + ) + } + + if let ocr = photo.ocrText, !ocr.isEmpty { + Text(ocr) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + + if let caption, !caption.isEmpty { + Text(caption) + .font(.footnote) + .foregroundColor(.primary) + } + } + .padding(.vertical, 8) + } + + private func loadImage() -> UIImage? { + let url: URL? = photo.localPath.hasPrefix("/") + ? URL(fileURLWithPath: photo.localPath) + : AudioRecordingManager.shared.getRecordingURL(fileName: photo.localPath) + guard let url else { return nil } + return UIImage(contentsOfFile: url.path) + } +} diff --git a/src/mobile/Muesli/Views/Components/TappableAttributedText.swift b/src/mobile/Muesli/Views/Components/TappableAttributedText.swift new file mode 100644 index 0000000..b8ad7fe --- /dev/null +++ b/src/mobile/Muesli/Views/Components/TappableAttributedText.swift @@ -0,0 +1,86 @@ +// +// TappableAttributedText.swift +// Muesli +// +// SwiftUI Text renders AttributedString but exposes no per-run gesture +// hook. This wraps UITextView so individual ranges in the augmented +// note body can be tapped to seek the chaptered playback view. +// + +import SwiftUI +import UIKit + +/// A region of the attributed text that the host wants to make tappable. +struct TappableTextTarget: Equatable { + /// NSRange in the rendered NSAttributedString. + let range: NSRange + /// Audio target in seconds. + let startSec: Double +} + +struct TappableAttributedText: UIViewRepresentable { + let attributed: AttributedString + let targets: [TappableTextTarget] + let onTap: (Double) -> Void + + func makeUIView(context: Context) -> UITextView { + let view = UITextView() + view.isEditable = false + view.isScrollEnabled = false + view.isSelectable = true + view.backgroundColor = .clear + view.textContainerInset = .zero + view.textContainer.lineFragmentPadding = 0 + view.dataDetectorTypes = [] + view.adjustsFontForContentSizeCategory = true + view.font = .preferredFont(forTextStyle: .body) + + let tap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(_:)) + ) + view.addGestureRecognizer(tap) + context.coordinator.textView = view + return view + } + + func updateUIView(_ uiView: UITextView, context: Context) { + let nsBase = NSAttributedString(attributed) + let ns = NSMutableAttributedString(attributedString: nsBase) + // Apply a baseline font so the system font + size match SwiftUI Text. + let full = NSRange(location: 0, length: ns.length) + ns.addAttribute(NSAttributedString.Key.font, value: UIFont.preferredFont(forTextStyle: .body), range: full) + uiView.attributedText = ns + context.coordinator.targets = targets + context.coordinator.onTap = onTap + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator: NSObject { + weak var textView: UITextView? + var targets: [TappableTextTarget] = [] + var onTap: (Double) -> Void = { _ in } + + @objc func handleTap(_ recognizer: UITapGestureRecognizer) { + guard let textView, recognizer.state == .ended else { return } + let layout = textView.layoutManager + let container = textView.textContainer + var location = recognizer.location(in: textView) + location.x -= textView.textContainerInset.left + location.y -= textView.textContainerInset.top + let charIndex = layout.characterIndex( + for: location, + in: container, + fractionOfDistanceBetweenInsertionPoints: nil + ) + guard charIndex >= 0 else { return } + // Pick the first target whose range contains the index. Targets + // are not expected to overlap (BlendRenderer produces flat + // ranges from char-offset arrays). + if let hit = targets.first(where: { NSLocationInRange(charIndex, $0.range) }) { + onTap(hit.startSec) + } + } + } +} diff --git a/src/mobile/Muesli/Views/Components/WaveformView.swift b/src/mobile/Muesli/Views/Components/WaveformView.swift index db362cb..154c862 100644 --- a/src/mobile/Muesli/Views/Components/WaveformView.swift +++ b/src/mobile/Muesli/Views/Components/WaveformView.swift @@ -2,18 +2,19 @@ // WaveformView.swift // Muesli // -// Animated waveform visualization component +// Animated waveform visualization component. 24 bars instead of the +// legacy 5, with per-bar phase offsets so the wave moves the way the +// mockup shows. Adapts to the system theme via Color.accentColor. // import SwiftUI struct WaveformView: View { - // Directly reference the shared manager - SwiftUI will observe it private let recordingManager = AudioRecordingManager.shared - @State private var waveformHeights: [CGFloat] = Array(repeating: 3, count: 5) + private static let barCount = 24 + @State private var waveformHeights: [CGFloat] = Array(repeating: 3, count: barCount) - // Computed properties from recording manager private var audioLevel: Float { recordingManager.audioLevel } @@ -22,29 +23,25 @@ struct WaveformView: View { recordingManager.state == .recording } - private let maxHeight: CGFloat = 30 + private let maxHeight: CGFloat = 36 private let minHeight: CGFloat = 3 private let barWidth: CGFloat = 3 - private let spacing: CGFloat = 4 + private let spacing: CGFloat = 3 var body: some View { - TimelineView(.periodic(from: .now, by: 0.1)) { context in + TimelineView(.periodic(from: .now, by: 0.06)) { context in HStack(spacing: spacing) { ForEach(0.. $1.timestamp } + } + + var body: some View { + List { + Section { + hero + .listRowInsets(EdgeInsets()) + .padding() + } + + Section("Talks · \(notes.count)") { + if notes.isEmpty { + Text("No talks yet.").foregroundColor(.secondary) + } else { + ForEach(notes) { note in + NavigationLink(value: note) { + NoteRow(note: note) + } + } + } + } + } + .navigationTitle(conference.name) + .navigationBarTitleDisplayMode(.inline) + } + + private var hero: some View { + VStack(alignment: .leading, spacing: 8) { + Text(conference.name) + .font(.largeTitle.weight(.bold)) + if let loc = conference.location { + Label(loc, systemImage: "mappin.and.ellipse") + .font(.subheadline) + .foregroundColor(.secondary) + } + if let range = Self.dateRangeString(conference: conference) { + Label(range, systemImage: "calendar") + .font(.subheadline) + .foregroundColor(.secondary) + } + if let desc = conference.conferenceDescription, !desc.isEmpty { + Text(desc) + .font(.body) + .padding(.top, 4) + } + + Button { + openChat() + } label: { + Label("Chat with this conference", systemImage: "bubble.left.and.bubble.right") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.top, 8) + } + .sheet(item: $chatThread) { thread in + ChatView( + thread: thread, + scopeTitle: "\(conference.name) · \(notes.count) talk\(notes.count == 1 ? "" : "s")" + ) + } + } + + private func openChat() { + let confId = conference.id + let conferenceRaw = ChatScopeKind.conference.rawValue + let predicate = #Predicate { + $0.scopeKindRaw == conferenceRaw && $0.scopeId == confId + } + if let existing = try? modelContext.fetch(FetchDescriptor(predicate: predicate)).first { + chatThread = existing + } else { + let thread = ChatThread(scopeKind: .conference, scopeId: conference.id) + modelContext.insert(thread) + try? modelContext.save() + chatThread = thread + } + } + + /// Builds a date-range string from explicit conference dates, falling back + /// to min/max of attached note timestamps. Same-year ranges drop the start + /// year ("Mar 14 – May 12, 2026"); cross-year ranges keep both years + /// ("Dec 30, 2025 – Jan 2, 2026"). Returns nil when no date info exists. + static func dateRangeString(conference: Conference) -> String? { + let start = conference.startDate ?? conference.notes.map(\.timestamp).min() + let end = conference.endDate ?? conference.notes.map(\.timestamp).max() + guard let start, let end else { return nil } + let cal = Calendar.current + if cal.isDate(start, inSameDayAs: end) { + return DateFormatter.localizedString(from: start, dateStyle: .medium, timeStyle: .none) + } + let sameYear = cal.component(.year, from: start) == cal.component(.year, from: end) + let endString = DateFormatter.localizedString(from: end, dateStyle: .medium, timeStyle: .none) + let startString: String + if sameYear { + let f = DateFormatter() + f.dateFormat = "MMM d" + startString = f.string(from: start) + } else { + startString = DateFormatter.localizedString(from: start, dateStyle: .medium, timeStyle: .none) + } + return "\(startString) – \(endString)" + } +} diff --git a/src/mobile/Muesli/Views/DebugMenuView.swift b/src/mobile/Muesli/Views/DebugMenuView.swift index df65b72..24e640b 100644 --- a/src/mobile/Muesli/Views/DebugMenuView.swift +++ b/src/mobile/Muesli/Views/DebugMenuView.swift @@ -13,7 +13,7 @@ struct DebugMenuView: View { @Environment(\.modelContext) private var modelContext @State private var showingAlert = false @State private var alertMessage = "" - + var body: some View { NavigationView { List { @@ -22,18 +22,18 @@ struct DebugMenuView: View { SampleDataManager.reseedDatabase(context: modelContext) showAlert("Sample data refreshed") } - + Button("Clear All Data") { SampleDataManager.clearAllData(context: modelContext) showAlert("All data cleared") } - + Button("Add More Sample Notes") { SampleDataManager.seedDatabase(context: modelContext) showAlert("Added more sample notes") } } - + Section("API Configuration") { HStack { Text("Environment") @@ -41,15 +41,15 @@ struct DebugMenuView: View { Text(APIConfiguration.environmentName) .foregroundColor(.secondary) } - + HStack { Text("API URL") Spacer() - Text(TranscriptionService.shared.isUsingLocalhost ? "Localhost" : "Remote") - .foregroundColor(TranscriptionService.shared.isUsingLocalhost ? .orange : .green) + Text(World.current.transcription.isUsingLocalhost ? "Localhost" : "Remote") + .foregroundColor(World.current.transcription.isUsingLocalhost ? .orange : .green) } } - + Section("Development Info") { HStack { Text("Build Configuration") @@ -57,11 +57,11 @@ struct DebugMenuView: View { Text("DEBUG") .foregroundColor(.orange) } - + HStack { Text("Current API") Spacer() - Text(TranscriptionService.shared.currentAPIEndpoint) + Text(World.current.transcription.currentAPIEndpoint) .font(.caption) .foregroundColor(.secondary) } @@ -76,7 +76,7 @@ struct DebugMenuView: View { } } } - + private func showAlert(_ message: String) { alertMessage = message showingAlert = true @@ -86,4 +86,4 @@ struct DebugMenuView: View { #Preview { DebugMenuView() } -#endif \ No newline at end of file +#endif diff --git a/src/mobile/Muesli/Views/DeveloperSettingsView.swift b/src/mobile/Muesli/Views/DeveloperSettingsView.swift index d28d8bf..823270f 100644 --- a/src/mobile/Muesli/Views/DeveloperSettingsView.swift +++ b/src/mobile/Muesli/Views/DeveloperSettingsView.swift @@ -1,5 +1,5 @@ // -// DeveloperStatusView.swift +// DeveloperStatusView.swift // Muesli // // Read-only view showing current API configuration (development only) @@ -10,7 +10,7 @@ import SwiftUI #if DEBUG struct DeveloperStatusView: View { @State private var transcriptionService = TranscriptionService.shared - + var body: some View { NavigationView { Form { @@ -21,14 +21,14 @@ struct DeveloperStatusView: View { Text(transcriptionService.environmentName) .foregroundColor(.secondary) } - + HStack { Label("Endpoint", systemImage: "link") Spacer() Text(transcriptionService.isUsingLocalhost ? "Localhost" : "Remote") .foregroundColor(transcriptionService.isUsingLocalhost ? .orange : .green) } - + VStack(alignment: .leading, spacing: 4) { Text("URL") .font(.caption) @@ -52,4 +52,4 @@ struct DeveloperStatusView: View { #Preview { DeveloperStatusView() } -#endif \ No newline at end of file +#endif diff --git a/src/mobile/Muesli/Views/EnhancedNoteEditorView.swift b/src/mobile/Muesli/Views/EnhancedNoteEditorView.swift deleted file mode 100644 index 9f47e2c..0000000 --- a/src/mobile/Muesli/Views/EnhancedNoteEditorView.swift +++ /dev/null @@ -1,207 +0,0 @@ -// -// EnhancedNoteEditorView.swift -// Muesli -// -// Enhanced note editor with formatting tools -// - -import SwiftUI - -struct EnhancedNoteEditorView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - - let note: Note - @State private var editedContent: String - @State private var showingError = false - @State private var errorMessage = "" - @State private var hasUnsavedChanges = false - - init(note: Note) { - self.note = note - self._editedContent = State(initialValue: note.content) - } - - var body: some View { - NavigationView { - VStack(spacing: 0) { - // Formatting Toolbar - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - FormatButton(icon: "textformat", title: "Header") { - insertText("# ") - } - - FormatButton(icon: "list.bullet", title: "Bullet") { - insertText("• ") - } - - FormatButton(icon: "list.bullet.indent", title: "Sub-bullet") { - insertText("○ ") - } - - Divider() - .frame(height: 20) - - FormatButton(icon: "bold", title: "Bold") { - wrapSelection("**", "**") - } - - FormatButton(icon: "italic", title: "Italic") { - wrapSelection("*", "*") - } - - Divider() - .frame(height: 20) - - FormatButton(icon: "checkmark.square", title: "Checklist") { - insertText("- [ ] ") - } - - FormatButton(icon: "link", title: "Link") { - wrapSelection("[", "](url)") - } - } - .padding(.horizontal, 16) - } - .padding(.vertical, 8) - .background(Color.gray.opacity(0.1)) - - Divider() - - // Content Editor - TextEditor(text: $editedContent) - .font(.body) - .foregroundColor(.white) - .scrollContentBackground(.hidden) - .background(Color.clear) - .padding() - .onChange(of: editedContent) { _, _ in - hasUnsavedChanges = true - } - - // Word count and status - HStack { - Text("\(wordCount(editedContent)) words") - .font(.caption) - .foregroundColor(.gray) - - Spacer() - - if hasUnsavedChanges { - HStack(spacing: 4) { - Circle() - .fill(Color.orange) - .frame(width: 6, height: 6) - Text("Unsaved changes") - .font(.caption) - .foregroundColor(.orange) - } - } - } - .padding(.horizontal) - .padding(.bottom, 8) - } - .background(Color.black) - .navigationTitle("Edit Note") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Cancel") { - if hasUnsavedChanges { - // Could add confirmation alert here - } - dismiss() - } - .foregroundColor(.white) - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button("Save") { - saveChanges() - } - .foregroundColor(.teal) - .disabled(!hasUnsavedChanges) - } - } - } - .preferredColorScheme(.dark) - .alert("Error", isPresented: $showingError) { - Button("OK") { } - } message: { - Text(errorMessage) - } - .onAppear { - AppLogger.shared.userAction("Open Enhanced Note Editor", context: note.title) - } - } - - // MARK: - Helper Methods - - private func insertText(_ text: String) { - editedContent += text - hasUnsavedChanges = true - AppLogger.shared.userAction("Insert Format", context: text.trimmingCharacters(in: .whitespaces)) - } - - private func wrapSelection(_ prefix: String, _ suffix: String) { - // For now, just append at the end since TextEditor selection is complex - editedContent += prefix + "text" + suffix - hasUnsavedChanges = true - AppLogger.shared.userAction("Apply Format", context: "\(prefix)...\(suffix)") - } - - private func wordCount(_ text: String) -> Int { - text.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - } - - private func saveChanges() { - do { - note.content = editedContent - try modelContext.save() - hasUnsavedChanges = false - AppLogger.shared.userAction("Save Enhanced Note Edit", context: note.title) - dismiss() - } catch { - showError("Failed to save note: \(error.localizedDescription)") - } - } - - private func showError(_ message: String) { - errorMessage = message - showingError = true - } -} - -struct FormatButton: View { - let icon: String - let title: String - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(spacing: 2) { - Image(systemName: icon) - .font(.system(size: 16)) - .foregroundColor(.teal) - - Text(title) - .font(.caption2) - .foregroundColor(.gray) - } - .frame(width: 60, height: 40) - } - .buttonStyle(PlainButtonStyle()) - } -} - -#Preview { - let sampleNote = Note( - title: "Sample Note", - content: "This is some sample content for editing.", - sessionType: "note" - ) - - EnhancedNoteEditorView(note: sampleNote) -} \ No newline at end of file diff --git a/src/mobile/Muesli/Views/MainView.swift b/src/mobile/Muesli/Views/MainView.swift new file mode 100644 index 0000000..0605fa2 --- /dev/null +++ b/src/mobile/Muesli/Views/MainView.swift @@ -0,0 +1,157 @@ +// +// MainView.swift +// Muesli +// +// Conference-grouped notes list. Sections by Conference (most recently +// active first), with an Other section for ungrouped notes. Each row +// pushes AugmentedNoteView via the navigation stack. +// + +import SwiftUI +import SwiftData + +struct MainView: View { + @Environment(\.modelContext) private var modelContext + @Query(filter: #Predicate { !$0.isArchived }, sort: \Note.timestamp, order: .reverse) + private var notes: [Note] + + // All conferences — including those whose notes are all archived — so a + // conference is still reachable on the home screen after archiving its talks. + @Query(sort: \Conference.createdAt, order: .reverse) + private var conferences: [Conference] + + @State private var showingNewNote = false + @State private var showingSignIn = false + + struct Group: Identifiable { + let conference: Conference? + let notes: [Note] + var id: String { conference?.id.uuidString ?? "other" } + } + + /// Build the section list. `allConferences` keeps conferences visible even + /// when all their notes are archived (or no notes exist yet). Sort order: + /// most-recent note timestamp descending, with conference name as a stable + /// tiebreaker; ungrouped notes always last. + static func partition(notes: [Note], allConferences: [Conference] = []) -> [Group] { + var byConferenceId: [UUID: (Conference, [Note])] = [:] + for conf in allConferences { + byConferenceId[conf.id] = (conf, []) + } + var ungrouped: [Note] = [] + for note in notes { + if let conf = note.conference { + if byConferenceId[conf.id] == nil { + byConferenceId[conf.id] = (conf, []) + } + byConferenceId[conf.id]?.1.append(note) + } else { + ungrouped.append(note) + } + } + var groups = byConferenceId.values.map { Group(conference: $0.0, notes: $0.1) } + groups.sort { a, b in + let aDate = a.notes.map(\.timestamp).max() ?? a.conference?.createdAt ?? .distantPast + let bDate = b.notes.map(\.timestamp).max() ?? b.conference?.createdAt ?? .distantPast + if aDate != bDate { return aDate > bDate } + // Stable tiebreaker on conference name. + return (a.conference?.name ?? "") < (b.conference?.name ?? "") + } + if !ungrouped.isEmpty { + groups.append(Group(conference: nil, notes: ungrouped)) + } + return groups + } + + private var groups: [Group] { Self.partition(notes: notes, allConferences: conferences) } + + var body: some View { + NavigationStack { + content + .navigationTitle("Notes") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showingNewNote = true + } label: { + Label("New note", systemImage: "plus.circle.fill") + } + } + } + .sheet(isPresented: $showingNewNote) { + NewNoteView() + } + .sheet(isPresented: $showingSignIn) { + SignInView { showingSignIn = false } + .interactiveDismissDisabled() + } + .task { + // Prompt for sign-in at launch when no token is cached. + if await AuthService.shared.isSignedIn() == false { + showingSignIn = true + } + } + .navigationDestination(for: Note.self) { note in + AugmentedNoteView(note: note) + } + .navigationDestination(for: Conference.self) { conference in + ConferenceDetailView(conference: conference) + } + } + } + + @ViewBuilder + private var content: some View { + if notes.isEmpty { + ContentUnavailableView( + "No notes yet", + systemImage: "doc.text", + description: Text("Tap + to record your first note.") + ) + } else { + List { + ForEach(groups) { group in + if let conference = group.conference { + Section { + // Conference acts as a tappable row above its + // talks. List section headers strip gestures from + // their content, so the conference link lives + // inside the section as a regular row instead. + NavigationLink(value: conference) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(conference.name) + .font(.headline) + if group.notes.isEmpty { + Text("No active talks") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("\(group.notes.count) talk\(group.notes.count == 1 ? "" : "s")") + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + } + } + ForEach(group.notes) { note in + NavigationLink(value: note) { + NoteRow(note: note) + } + } + } + } else { + Section("Other") { + ForEach(group.notes) { note in + NavigationLink(value: note) { + NoteRow(note: note) + } + } + } + } + } + } + } + } +} diff --git a/src/mobile/Muesli/Views/MyNotesView.swift b/src/mobile/Muesli/Views/MyNotesView.swift deleted file mode 100644 index c3e341c..0000000 --- a/src/mobile/Muesli/Views/MyNotesView.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// MyNotesView.swift -// Muesli -// -// Created by Travis Frisinger on 8/25/25. -// - -import SwiftUI - -struct MyNotesView: View { - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - let note: Note - @State private var editedNotes: String = "" - - var body: some View { - NavigationView { - ZStack { - Color.black.ignoresSafeArea() - - VStack(alignment: .leading, spacing: 16) { - Text("Edit your personal notes:") - .font(.headline) - .foregroundColor(.white) - .padding(.horizontal, 20) - .padding(.top, 20) - - TextEditor(text: $editedNotes) - .foregroundColor(.white) - .scrollContentBackground(.hidden) - .background(Color.gray.opacity(0.1)) - .cornerRadius(8) - .padding(.horizontal, 20) - .onChange(of: editedNotes) { _, newValue in - saveNotes(newValue) - } - - Spacer() - } - } - .navigationTitle("My Notes") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { - dismiss() - } - .foregroundColor(.teal) - } - } - } - .preferredColorScheme(.dark) - .onAppear { - editedNotes = note.userNotes - } - } - - private func saveNotes(_ newNotes: String) { - note.userNotes = newNotes - // Regenerate summary with updated user notes - note.aiSummary = SimpleSummaryGenerator.generateSummary(from: note.content, userNotes: newNotes) - do { - try modelContext.save() - } catch { - AppLogger.shared.error("Failed to save user notes", error: error) - } - } -} \ No newline at end of file diff --git a/src/mobile/Muesli/Views/NewNoteView.swift b/src/mobile/Muesli/Views/NewNoteView.swift index f6ac082..690fe26 100644 --- a/src/mobile/Muesli/Views/NewNoteView.swift +++ b/src/mobile/Muesli/Views/NewNoteView.swift @@ -19,18 +19,18 @@ struct CapturedImage: Identifiable { struct NewNoteView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext - + // Recording state @State private var recordingManager = AudioRecordingManager.shared @State private var transcriptionService = HybridTranscriptionService.shared @State private var networkMonitor = NetworkMonitor.shared - + // Note properties @State private var title = "" @State private var userNotes = "" // User's typed notes during recording @State private var conferenceName = "" @State private var sessionType = "note" - + // UI state @State private var showingError = false @State private var errorMessage = "" @@ -40,9 +40,9 @@ struct NewNoteView: View { @State private var capturedImages: [CapturedImage] = [] @State private var userEndedRecording = false @State private var recordingStartTime: Date? - + private let sessionTypes = ["note", "meeting", "session"] - + // Computed property to show appropriate icon based on availability private var cameraIconName: String { #if targetEnvironment(simulator) @@ -51,18 +51,18 @@ struct NewNoteView: View { return UIImagePickerController.isSourceTypeAvailable(.camera) ? "camera.fill" : "photo.on.rectangle" #endif } - + // Prevent accidental pause button presses right after recording starts private var shouldDisablePauseButton: Bool { guard let startTime = recordingStartTime else { return false } return Date().timeIntervalSince(startTime) < 2.0 // Disable for first 2 seconds } - + var body: some View { NavigationView { ZStack { Color.black.ignoresSafeArea() - + VStack(spacing: 0) { Spacer() @@ -76,7 +76,7 @@ struct NewNoteView: View { } .padding(.horizontal, 20) .padding(.bottom, 16) - + // Note card (current recording) VStack(spacing: 0) { HStack { @@ -86,25 +86,25 @@ struct NewNoteView: View { .frame(width: 40, height: 40) .background(Color.teal.opacity(0.2)) .cornerRadius(8) - + VStack(alignment: .leading, spacing: 4) { TextField("New Note", text: $title) .foregroundColor(.white) .font(.system(size: 16, weight: .medium)) .textFieldStyle(PlainTextFieldStyle()) - + HStack(spacing: 8) { Text(formatTime(recordingManager.recordingDuration)) .foregroundColor(.gray) .font(.system(size: 14)) - + // Recording mode indicator if recordingManager.state == .recording || recordingManager.state == .paused { HStack(spacing: 4) { Image(systemName: isOnlineMode ? "wifi" : "wifi.slash") .foregroundColor(isOnlineMode ? .green : .orange) .font(.system(size: 12)) - + Text(isOnlineMode ? "Live transcription" : "Local recording") .foregroundColor(isOnlineMode ? .green : .orange) .font(.system(size: 12, weight: .medium)) @@ -112,9 +112,9 @@ struct NewNoteView: View { } } } - + Spacer() - + // Camera Button Button(action: { showingImagePicker = true @@ -129,13 +129,13 @@ struct NewNoteView: View { } .padding(.horizontal, 20) .padding(.top, 16) - + // Text input area VStack(alignment: .leading, spacing: 8) { Divider() .background(Color.gray.opacity(0.3)) .padding(.horizontal, 20) - + TextField("Feel free to write notes here...", text: $userNotes, axis: .vertical) .foregroundColor(.white.opacity(0.9)) .font(.body) @@ -145,19 +145,19 @@ struct NewNoteView: View { .padding(.horizontal, 20) .frame(minHeight: 80) } - + // Captured images section if !capturedImages.isEmpty { VStack(alignment: .leading, spacing: 8) { Divider() .background(Color.gray.opacity(0.3)) .padding(.horizontal, 20) - + Text("Attached images") .foregroundColor(.gray) .font(.caption) .padding(.horizontal, 20) - + ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(capturedImages) { capturedImage in @@ -167,7 +167,7 @@ struct NewNoteView: View { .aspectRatio(contentMode: .fill) .frame(width: 80, height: 100) .clipShape(RoundedRectangle(cornerRadius: 8)) - + Text(formatImageTimestamp(capturedImage.timestamp)) .font(.caption2) .foregroundColor(.gray) @@ -178,15 +178,15 @@ struct NewNoteView: View { } } } - + Spacer() } .background(Color.gray.opacity(0.15)) .cornerRadius(12) .padding(.horizontal, 20) - + Spacer() - + // Recording controls recordingControlsView } @@ -203,7 +203,7 @@ struct NewNoteView: View { .font(.system(size: 18, weight: .medium)) } } - + ToolbarItem(placement: .navigationBarTrailing) { Button { // User profile placeholder @@ -246,9 +246,9 @@ struct NewNoteView: View { } } } - + // MARK: - Recording Controls View - + @ViewBuilder private var recordingControlsView: some View { HStack(spacing: 30) { @@ -267,19 +267,19 @@ struct NewNoteView: View { ) } .disabled(recordingManager.state == .idle || shouldDisablePauseButton) - + // Waveform and timer VStack(spacing: 8) { WaveformView() - .onChange(of: recordingManager.state) { oldValue, newValue in - AppLogger.shared.info("UI: Recording state changed from \(oldValue) to \(newValue)") - } - .onChange(of: recordingManager.audioLevel) { oldValue, newValue in - if abs(newValue - oldValue) > 0.1 { - AppLogger.shared.info("UI: Audio level changed from \(oldValue) to \(newValue)") + .onChange(of: recordingManager.state) { oldValue, newValue in + AppLogger.shared.info("UI: Recording state changed from \(oldValue) to \(newValue)") } - } - + .onChange(of: recordingManager.audioLevel) { oldValue, newValue in + if abs(newValue - oldValue) > 0.1 { + AppLogger.shared.info("UI: Audio level changed from \(oldValue) to \(newValue)") + } + } + Text(formatTime(recordingManager.recordingDuration)) .font(.system(size: 18, weight: .medium)) .foregroundColor(.green) @@ -290,27 +290,27 @@ struct NewNoteView: View { } } } - - // End Button + + // Stop Button — square stop icon matching the mockup. Button(action: { endRecording() }) { - Text("End") - .font(.system(size: 16, weight: .semibold)) + Image(systemName: "stop.fill") + .font(.system(size: 20, weight: .bold)) .foregroundColor(.white) - .padding(.horizontal, 32) - .padding(.vertical, 16) - .background(Color.teal) - .cornerRadius(25) + .frame(width: 56, height: 56) + .background(Color.red) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .accessibilityLabel("Stop recording") } .disabled(recordingManager.state == .idle) } .padding(.horizontal, 20) .padding(.bottom, 40) } - + // MARK: - Helper Methods - + private func setupRecording() { // Check permissions recordingManager.checkPermission() @@ -328,48 +328,47 @@ struct NewNoteView: View { await startRecording() } } - + // Setup transcription callbacks - transcriptionService.onTranscriptionUpdate = { transcript, isFinal in + transcriptionService.onTranscriptionUpdate = { _, _ in // Live transcription not used - we do batch transcription after recording } - + transcriptionService.onError = { error in DispatchQueue.main.async { // Gracefully handle transcription errors without disrupting the user AppLogger.shared.warning("Transcription service error - continuing with local recording: \(error.localizedDescription)") - + // Switch to offline mode instead of showing error self.isOnlineMode = false } } - + // Note: API endpoint should be configured programmatically or via settings // No need to prompt user for API keys on device } - + private func startRecording() async { do { // Start recording (always works locally) _ = try await recordingManager.startRecording() - + // Set recording start time for UI protection recordingStartTime = Date() - + // Try to start real-time transcription if possible isOnlineMode = await tryStartTranscription() - + // UI timer is handled by AudioRecordingManager - + let mode = isOnlineMode ? "Online with transcription" : "Offline (local recording only)" AppLogger.shared.info("Recording started - Mode: \(mode)") - } catch { recordingStartTime = nil showError("Failed to start recording: \(error.localizedDescription)") } } - + private func tryStartTranscription() async -> Bool { // Check if any transcription service is available guard transcriptionService.isLocalAvailable || transcriptionService.isCloudAvailable else { @@ -387,11 +386,11 @@ struct NewNoteView: View { return false } } - + private func handleResumeOrPause() { AppLogger.shared.info("handleResumeOrPause called - current state: \(recordingManager.state)") AppLogger.shared.info("Call Stack: \(Thread.callStackSymbols.prefix(3).joined(separator: "\n"))") - + switch recordingManager.state { case .recording: AppLogger.shared.info("Pausing recording from handleResumeOrPause") @@ -399,20 +398,19 @@ struct NewNoteView: View { case .paused: AppLogger.shared.info("Resuming recording from handleResumeOrPause") resumeRecording() - default: + default: AppLogger.shared.warning("handleResumeOrPause called with unexpected state: \(recordingManager.state)") - break } } - + private func pauseRecording() { recordingManager.pauseRecording() - + if isOnlineMode { transcriptionService.stopRealtimeTranscription() } } - + private func resumeRecording() { recordingManager.resumeRecording() @@ -429,28 +427,28 @@ struct NewNoteView: View { } } } - + private func endRecording() { AppLogger.shared.info("endRecording called from NewNoteView - user initiated: \(userEndedRecording)") AppLogger.shared.info("Call Stack: \(Thread.callStackSymbols.prefix(5).joined(separator: "\n"))") - + // Only proceed if we're actually recording guard recordingManager.state == .recording || recordingManager.state == .paused else { AppLogger.shared.warning("endRecording called but recording state is: \(recordingManager.state)") return } - + userEndedRecording = true recordingManager.stopRecording() - + if isOnlineMode { transcriptionService.stopRealtimeTranscription() } - + // Save the note saveNote() } - + private func saveNote() { do { let conferenceValue = conferenceName.isEmpty ? nil : conferenceName @@ -508,18 +506,17 @@ struct NewNoteView: View { audioPath: audioPath ) } - + AppLogger.shared.info("Note saved - Duration: \(recordingManager.recordingDuration)s, Transcription: \(transcriptionStatus)") dismiss() - } catch { showError("Failed to save note: \(error.localizedDescription)") } } - + private func cleanup() { AppLogger.shared.info("cleanup called - recording state: \(recordingManager.state), userEndedRecording: \(userEndedRecording)") - + // Only cancel if user didn't properly end the recording if (recordingManager.state == .recording || recordingManager.state == .paused) && !userEndedRecording { AppLogger.shared.info("cancelling active recording in cleanup because user didn't end it properly") @@ -528,31 +525,29 @@ struct NewNoteView: View { AppLogger.shared.info("recording is still active but user ended it - stopping normally") recordingManager.stopRecording() } - + if transcriptionService.isTranscribing { AppLogger.shared.info("stopping transcription in cleanup") transcriptionService.stopRealtimeTranscription() } - + // Reset flags userEndedRecording = false recordingStartTime = nil } - - private func formatTime(_ time: TimeInterval) -> String { let minutes = Int(time) / 60 let seconds = Int(time) % 60 return String(format: "%02d:%02d", minutes, seconds) } - + private func formatImageTimestamp(_ timestamp: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss" return formatter.string(from: timestamp) } - + private func addCapturedImage(_ image: UIImage) { let capturedImage = CapturedImage(image: image, timestamp: Date()) capturedImages.append(capturedImage) @@ -596,4 +591,4 @@ struct NewNoteView: View { errorMessage = message showingError = true } -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Views/PerformanceView.swift b/src/mobile/Muesli/Views/PerformanceView.swift index 87d09d4..d8531bb 100644 --- a/src/mobile/Muesli/Views/PerformanceView.swift +++ b/src/mobile/Muesli/Views/PerformanceView.swift @@ -10,7 +10,7 @@ import SwiftUI struct PerformanceView: View { @ObservedObject private var performanceMonitor = PerformanceMonitor.shared @State private var showingDetailedReport = false - + var body: some View { NavigationView { VStack(alignment: .leading, spacing: 20) { @@ -20,31 +20,31 @@ struct PerformanceView: View { count: performanceMonitor.metrics.dataOperations.count, averageTime: averageTime(for: performanceMonitor.metrics.dataOperations.map(\.duration)) ) - + StatsCardView( - title: "Search Operations", + title: "Search Operations", count: performanceMonitor.metrics.searchOperations.count, averageTime: averageTime(for: performanceMonitor.metrics.searchOperations.map(\.duration)) ) - + StatsCardView( title: "Write Operations", count: performanceMonitor.metrics.writeOperations.count, averageTime: averageTime(for: performanceMonitor.metrics.writeOperations.map(\.duration)) ) - + // Memory Usage if let currentMemory = performanceMonitor.metrics.memoryUsage.last { VStack(alignment: .leading, spacing: 8) { Text("Memory Usage") .font(.headline) .foregroundColor(.white) - + Text("\(String(format: "%.1f", currentMemory.usageMB)) MB") .font(.title2) .fontWeight(.bold) .foregroundColor(.teal) - + Text("Last updated: \(formatTime(currentMemory.timestamp))") .font(.caption) .foregroundColor(.gray) @@ -53,7 +53,7 @@ struct PerformanceView: View { .background(Color.gray.opacity(0.15)) .cornerRadius(12) } - + // Action Buttons VStack(spacing: 12) { Button("View Detailed Report") { @@ -64,7 +64,7 @@ struct PerformanceView: View { .padding() .background(Color.teal.opacity(0.2)) .cornerRadius(12) - + Button("Clear Metrics") { clearMetrics() } @@ -74,7 +74,7 @@ struct PerformanceView: View { .background(Color.red.opacity(0.2)) .cornerRadius(12) } - + Spacer() } .padding() @@ -87,19 +87,19 @@ struct PerformanceView: View { DetailedPerformanceReportView() } } - + private func averageTime(for durations: [TimeInterval]) -> String { guard !durations.isEmpty else { return "N/A" } let average = durations.reduce(0, +) / Double(durations.count) - return String(format: "%.1f ms", average * 1000) + return String(format: "%.1f ms", average * 1_000) } - + private func formatTime(_ date: Date) -> String { let formatter = DateFormatter() formatter.timeStyle = .short return formatter.string(from: date) } - + private func clearMetrics() { // Note: In a real implementation, you'd want to add a clearMetrics method to PerformanceMonitor AppLogger.shared.info("Performance metrics cleared") @@ -110,13 +110,13 @@ struct StatsCardView: View { let title: String let count: Int let averageTime: String - + var body: some View { VStack(alignment: .leading, spacing: 8) { Text(title) .font(.headline) .foregroundColor(.white) - + HStack { VStack(alignment: .leading) { Text("\(count)") @@ -127,9 +127,9 @@ struct StatsCardView: View { .font(.caption) .foregroundColor(.gray) } - + Spacer() - + VStack(alignment: .trailing) { Text(averageTime) .font(.title3) @@ -150,7 +150,7 @@ struct StatsCardView: View { struct DetailedPerformanceReportView: View { @Environment(\.dismiss) private var dismiss private let report = PerformanceMonitor.shared.generatePerformanceReport() - + var body: some View { NavigationView { ScrollView { @@ -179,4 +179,4 @@ struct DetailedPerformanceReportView: View { #Preview { PerformanceView() } -#endif \ No newline at end of file +#endif diff --git a/src/mobile/Muesli/Views/ProfileView.swift b/src/mobile/Muesli/Views/ProfileView.swift index e3e60c8..bd75a21 100644 --- a/src/mobile/Muesli/Views/ProfileView.swift +++ b/src/mobile/Muesli/Views/ProfileView.swift @@ -15,9 +15,9 @@ struct ProfileView: View { @AppStorage("defaultSessionType") private var defaultSessionType = "note" @AppStorage("enableNotifications") private var enableNotifications = true @AppStorage("autoArchiveOldNotes") private var autoArchiveOldNotes = false - + private let sessionTypes = ["note", "meeting", "session"] - + var body: some View { NavigationView { Form { @@ -27,28 +27,28 @@ struct ProfileView: View { Image(systemName: "person.circle.fill") .foregroundColor(.teal) .font(.system(size: 50)) - + VStack(alignment: .leading, spacing: 4) { Text(displayName.isEmpty ? "Your Name" : displayName) .font(.title3) .fontWeight(.semibold) .foregroundColor(.white) - + Text(email.isEmpty ? "your.email@example.com" : email) .font(.subheadline) .foregroundColor(.gray) } - + Spacer() } .padding(.vertical, 8) - + LabeledContent("Display Name") { TextField("Enter your name", text: $displayName) .textFieldStyle(.roundedBorder) .frame(maxWidth: 200) } - + LabeledContent("Email") { TextField("Enter your email", text: $email) .textFieldStyle(.roundedBorder) @@ -56,14 +56,14 @@ struct ProfileView: View { .autocapitalization(.none) .frame(maxWidth: 200) } - + LabeledContent("Organization") { TextField("Enter organization", text: $organization) .textFieldStyle(.roundedBorder) .frame(maxWidth: 200) } } - + // Preferences Section Section("Preferences") { Picker("Default Session Type", selection: $defaultSessionType) { @@ -72,12 +72,12 @@ struct ProfileView: View { } } .pickerStyle(.menu) - + Toggle("Enable Notifications", isOn: $enableNotifications) - + Toggle("Auto-archive old notes", isOn: $autoArchiveOldNotes) } - + // Statistics Section Section("Statistics") { StatisticRow( @@ -86,14 +86,14 @@ struct ProfileView: View { value: "Loading...", color: .teal ) - + StatisticRow( icon: "archivebox", - title: "Archived Notes", + title: "Archived Notes", value: "Loading...", color: .orange ) - + StatisticRow( icon: "calendar", title: "Days Active", @@ -101,14 +101,14 @@ struct ProfileView: View { color: .green ) } - + // Actions Section Section("Actions") { Button("Export All Notes") { exportAllNotes() } .foregroundColor(.teal) - + Button("Reset All Settings") { resetSettings() } @@ -131,15 +131,15 @@ struct ProfileView: View { AppLogger.shared.userAction("View Profile") } } - + // MARK: - Helper Methods - + private func exportAllNotes() { // TODO: Implement note export functionality AppLogger.shared.userAction("Export All Notes Requested") // For now, just log the action } - + private func resetSettings() { displayName = "" email = "" @@ -156,19 +156,19 @@ struct StatisticRow: View { let title: String let value: String let color: Color - + var body: some View { HStack { Image(systemName: icon) .foregroundColor(color) .font(.system(size: 20)) .frame(width: 30) - + Text(title) .foregroundColor(.white) - + Spacer() - + Text(value) .foregroundColor(.gray) .font(.system(.body, design: .monospaced)) @@ -178,4 +178,4 @@ struct StatisticRow: View { #Preview { ProfileView() -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/Views/SimpleSettingsView.swift b/src/mobile/Muesli/Views/SettingsView.swift similarity index 92% rename from src/mobile/Muesli/Views/SimpleSettingsView.swift rename to src/mobile/Muesli/Views/SettingsView.swift index d5bfc7a..d43ae78 100644 --- a/src/mobile/Muesli/Views/SimpleSettingsView.swift +++ b/src/mobile/Muesli/Views/SettingsView.swift @@ -1,5 +1,5 @@ // -// SimpleSettingsView.swift +// SettingsView.swift // Muesli // // Created by Travis Frisinger on 8/25/25. @@ -8,20 +8,20 @@ import SwiftUI import SwiftData -struct SimpleSettingsView: View { +struct SettingsView: View { @Environment(\.dismiss) private var dismiss @Binding var showingArchive: Bool @State private var showingPerformance = false @State private var showingProfile = false - + @Query(filter: #Predicate { $0.isArchived }) private var archivedNotes: [Note] - + private var archivedCount: Int { archivedNotes.count } - + var body: some View { NavigationView { VStack(spacing: 24) { @@ -37,13 +37,13 @@ struct SimpleSettingsView: View { .foregroundColor(.gray) .font(.system(size: 20)) .frame(width: 24) - + Text("Profile") .foregroundColor(.white) .font(.system(size: 16, weight: .medium)) - + Spacer() - + Image(systemName: "chevron.right") .foregroundColor(.gray) .font(.system(size: 14)) @@ -53,7 +53,7 @@ struct SimpleSettingsView: View { .cornerRadius(12) } .buttonStyle(PlainButtonStyle()) - + // Archive Section Button(action: { dismiss() @@ -66,13 +66,13 @@ struct SimpleSettingsView: View { .foregroundColor(.gray) .font(.system(size: 20)) .frame(width: 24) - + VStack(alignment: .leading, spacing: 2) { Text("Archive") .foregroundColor(.white) .font(.system(size: 16, weight: .medium)) .frame(maxWidth: .infinity, alignment: .leading) - + if archivedCount > 0 { Text("\(archivedCount) archived notes") .foregroundColor(.gray) @@ -80,7 +80,7 @@ struct SimpleSettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) } } - + Spacer() } .padding() @@ -88,7 +88,7 @@ struct SimpleSettingsView: View { .cornerRadius(12) } .buttonStyle(PlainButtonStyle()) - + // Performance section (Debug only) #if DEBUG Button(action: { @@ -102,12 +102,12 @@ struct SimpleSettingsView: View { .foregroundColor(.gray) .font(.system(size: 20)) .frame(width: 24) - + Text("Performance Monitor") .foregroundColor(.white) .font(.system(size: 16, weight: .medium)) .frame(maxWidth: .infinity, alignment: .leading) - + Text("DEBUG") .font(.caption) .foregroundColor(.orange) @@ -115,7 +115,7 @@ struct SimpleSettingsView: View { .padding(.vertical, 2) .background(Color.orange.opacity(0.2)) .cornerRadius(4) - + Spacer() } .padding() @@ -124,7 +124,7 @@ struct SimpleSettingsView: View { } .buttonStyle(PlainButtonStyle()) #endif - + Spacer() } .padding(.horizontal, 20) @@ -152,6 +152,6 @@ struct SimpleSettingsView: View { } #Preview { - SimpleSettingsView(showingArchive: .constant(false)) + SettingsView(showingArchive: .constant(false)) .modelContainer(for: Note.self, inMemory: true) } diff --git a/src/mobile/Muesli/Views/SignInView.swift b/src/mobile/Muesli/Views/SignInView.swift new file mode 100644 index 0000000..2f3ed68 --- /dev/null +++ b/src/mobile/Muesli/Views/SignInView.swift @@ -0,0 +1,99 @@ +// +// SignInView.swift +// Muesli +// +// Dev sign-in screen: enter an email, hit Sign in, the backend mints a +// token and the rest of the app picks it up via TokenStore. Wired into +// the app at launch when no access token exists yet. +// + +import SwiftUI + +struct SignInView: View { + @State private var email: String = "" + @State private var fullName: String = "" + @State private var isSigningIn = false + @State private var errorMessage: String? + + /// Called after a successful sign-in so the host can dismiss / refresh. + var onSignedIn: () -> Void = {} + + var body: some View { + NavigationStack { + VStack(spacing: 16) { + Spacer() + Image(systemName: "doc.text.magnifyingglass") + .font(.system(size: 56)) + .foregroundColor(.accentColor) + Text("Sign in to Muesli") + .font(.title2.weight(.bold)) + Text("Dev sign-in for non-production backends. Production sign-in via Google is a future step.") + .font(.footnote) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + VStack(spacing: 12) { + TextField("Email", text: $email) + .textFieldStyle(.roundedBorder) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField("Display name (optional)", text: $fullName) + .textFieldStyle(.roundedBorder) + } + .padding(.horizontal, 32) + .padding(.top, 16) + + if let errorMessage { + Text(errorMessage) + .font(.footnote) + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + Button { + Task { await submit() } + } label: { + if isSigningIn { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } else { + Text("Sign in") + .frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .padding(.horizontal, 32) + .disabled(isSigningIn || !isEmailValid) + + Spacer() + } + .navigationTitle("Welcome") + .navigationBarTitleDisplayMode(.inline) + } + } + + private var isEmailValid: Bool { + let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.contains("@") && trimmed.contains(".") + } + + private func submit() async { + isSigningIn = true + errorMessage = nil + defer { isSigningIn = false } + do { + try await AuthService.shared.signInDev( + email: email.trimmingCharacters(in: .whitespacesAndNewlines), + fullName: fullName.isEmpty ? nil : fullName + ) + onSignedIn() + } catch { + errorMessage = (error as? AuthError)?.errorDescription ?? error.localizedDescription + } + } +} diff --git a/src/mobile/Muesli/Views/SimpleMainView.swift b/src/mobile/Muesli/Views/SimpleMainView.swift deleted file mode 100644 index 49462b6..0000000 --- a/src/mobile/Muesli/Views/SimpleMainView.swift +++ /dev/null @@ -1,342 +0,0 @@ -// -// SimpleMainView.swift -// Muesli -// -// Created by Travis Frisinger on 8/25/25. -// - -import SwiftUI -import SwiftData - -struct SimpleMainView: View { - @Environment(\.modelContext) private var modelContext - @Query(filter: #Predicate { !$0.isArchived }, sort: \Note.timestamp, order: .reverse) - private var notes: [Note] - - @State private var searchText = "" - @State private var showingNewNote = false - @State private var showingSettings = false - @State private var showingArchive = false - @State private var selectedNote: Note? = nil - @State private var showingEditAlert = false - @State private var editingNote: Note? - @State private var editingTitle = "" - @State private var searchResults: [Note] = [] - @State private var isSearching = false - - private var displayedNotes: [Note] { - if isSearching && !searchText.isEmpty { - return searchResults - } - return notes - } - - var body: some View { - NavigationView { - ZStack { - Color.black.ignoresSafeArea() - - VStack(spacing: 0) { - // Header - MainHeaderView { - showingSettings = true - } - - // Search bar - SearchBarView(searchText: $searchText) { newValue in - handleSearchTextChange(newValue) - } - - // Notes list - NotesListView( - notes: displayedNotes, - onNoteTap: { note in - selectedNote = note - }, - onNoteEdit: { note in - editingNote = note - editingTitle = note.title - showingEditAlert = true - }, - onNoteArchive: { note in - archiveNote(note) - }, - onProcessTranscription: { note in - processTranscription(for: note) - } - ) - - Spacer() - } - - // Floating action button - FloatingActionButton { - showingNewNote = true - } - } - } - .sheet(isPresented: $showingNewNote) { - NewNoteView() - } - .sheet(isPresented: $showingSettings) { - SimpleSettingsView(showingArchive: $showingArchive) - } - .sheet(isPresented: $showingArchive) { - SimpleArchiveView() - } - .sheet(item: $selectedNote) { note in - SimpleNoteDetailView(note: note) - } - .alert("Edit Title", isPresented: $showingEditAlert) { - TextField("Note title", text: $editingTitle) - - Button("Cancel", role: .cancel) { - editingNote = nil - editingTitle = "" - } - - Button("Save") { - saveEditedTitle() - } - .disabled(editingTitle.isEmpty) - } message: { - Text("Enter a new title for this note") - } - .preferredColorScheme(.dark) - .onAppear { - // Give data more time to load before debug - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - debugNotes() - } - } - } - - // MARK: - Helper Methods - - private func debugNotes() { - AppLogger.shared.viewLifecycle("SimpleMainView", event: .load) - AppLogger.shared.debug("SimpleMainView loaded with \(notes.count) notes") - for (index, note) in notes.enumerated() { - let contentInfo = note.content.isEmpty ? "EMPTY" : "\(note.content.count) chars" - AppLogger.shared.debug("Note \(index): '\(note.title)' - content: \(contentInfo)") - } - } - - private func handleSearchTextChange(_ newValue: String) { - if newValue.isEmpty { - isSearching = false - searchResults = [] - } else { - isSearching = true - // Simple search using SwiftData directly - let descriptor = FetchDescriptor( - predicate: #Predicate { note in - note.title.localizedStandardContains(newValue) && !note.isArchived - } - ) - do { - searchResults = try modelContext.fetch(descriptor) - AppLogger.shared.searchOperation(query: newValue, resultCount: searchResults.count) - } catch { - AppLogger.shared.dataError("Local Search", error: error, details: "Query: '\(newValue)'") - searchResults = [] - } - } - } - - private func archiveNote(_ note: Note) { - do { - note.isArchived = true - try modelContext.save() - AppLogger.shared.noteOperation(.archive, title: note.title) - AppLogger.shared.userAction("Archive Note", context: note.title) - } catch { - AppLogger.shared.dataError("Archive Note", error: error, details: "Title: \(note.title)") - } - } - - private func saveEditedTitle() { - guard let note = editingNote else { return } - - do { - let oldTitle = note.title - note.title = editingTitle - try modelContext.save() - AppLogger.shared.noteOperation(.update, title: editingTitle) - AppLogger.shared.userAction("Edit Title", context: "'\(oldTitle)' → '\(editingTitle)'") - editingNote = nil - editingTitle = "" - } catch { - AppLogger.shared.dataError("Update Note Title", error: error, details: "Title: \(editingTitle)") - } - } - - private func processTranscription(for note: Note) { - guard note.needsTranscription, - let audioFilePath = note.audioFilePath, - let audioURL = AudioRecordingManager.shared.getRecordingURL(fileName: audioFilePath) else { - AppLogger.shared.warning("Cannot process transcription - invalid audio file") - return - } - - // Check network connectivity - guard NetworkMonitor.shared.isConnected else { - AppLogger.shared.warning("Cannot process transcription - no internet connection") - return - } - - // Update status to processing - note.transcriptionStatus = "processing" - do { - try modelContext.save() - } catch { - AppLogger.shared.dataError("Update Note Status", error: error) - return - } - - // Process transcription with hybrid service - Task { - do { - let transcript = try await HybridTranscriptionService.shared.transcribeAudioFile(url: audioURL) - - await MainActor.run { - note.content = transcript - note.transcriptionStatus = "completed" - note.title = SimpleSummaryGenerator.generateTitle(from: transcript) - note.aiSummary = SimpleSummaryGenerator.generateSummary(from: transcript, userNotes: note.userNotes) - - do { - try modelContext.save() - AppLogger.shared.info("Successfully transcribed note: \(note.title) (\(transcript.count) chars)") - } catch { - AppLogger.shared.dataError("Save Transcription", error: error) - note.transcriptionStatus = "failed" - } - } - } catch { - await MainActor.run { - note.transcriptionStatus = "failed" - do { - try modelContext.save() - } catch { - AppLogger.shared.dataError("Update Failed Status", error: error) - } - } - AppLogger.shared.info("Transcription failed for note: \(note.title) - \(error.localizedDescription)") - } - } - } -} - -// Simple, standard note card -struct SimpleNoteCard: View { - let note: Note - let onTap: () -> Void - let onEdit: () -> Void - let onArchive: () -> Void - let onProcessTranscription: (() -> Void)? - - var body: some View { - Button(action: onTap) { - HStack { - // Icon based on audio status - Image(systemName: note.hasAudio ? "waveform" : "doc.text") - .foregroundColor(note.hasAudio ? .orange : .teal) - .font(.system(size: 20)) - .frame(width: 40, height: 40) - .background((note.hasAudio ? Color.orange : Color.teal).opacity(0.2)) - .cornerRadius(8) - - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(note.title) - .foregroundColor(.white) - .font(.system(size: 16, weight: .medium)) - .lineLimit(1) - - Spacer() - - // Transcription status indicators - if note.hasAudio { - if note.isTranscribing { - HStack(spacing: 4) { - ProgressView() - .scaleEffect(0.7) - .progressViewStyle(CircularProgressViewStyle(tint: .orange)) - Text("Processing") - .font(.caption) - .foregroundColor(.orange) - } - } else if note.needsTranscription { - Button(action: { - onProcessTranscription?() - }) { - HStack(spacing: 4) { - Image(systemName: "doc.text.below.ecg") - .font(.caption) - Text("Transcribe") - .font(.caption) - } - .foregroundColor(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.orange) - .cornerRadius(6) - } - .buttonStyle(PlainButtonStyle()) - } else if note.transcriptionStatus == "completed" { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.caption) - } - } - } - - HStack { - Text(note.timeString) - .foregroundColor(.gray) - .font(.system(size: 14)) - - if note.hasAudio && (note.duration ?? 0) > 0 { - Text("• \(note.durationString)") - .foregroundColor(.gray) - .font(.system(size: 14)) - } - } - } - - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background(Color.gray.opacity(0.15)) - .cornerRadius(12) - } - .buttonStyle(PlainButtonStyle()) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button { - onArchive() - } label: { - Label("Archive", systemImage: "archivebox") - } - .tint(.orange) - } - .swipeActions(edge: .leading) { - Button { - onEdit() - } label: { - Label("Edit", systemImage: "pencil") - } - .tint(.blue) - } - .contextMenu { - Button("Edit Title", systemImage: "pencil", action: onEdit) - Button("Archive", systemImage: "archivebox", action: onArchive) - } - } -} - -#Preview { - SimpleMainView() - .modelContainer(for: Note.self, inMemory: true) -} \ No newline at end of file diff --git a/src/mobile/Muesli/Views/SimpleNoteDetailView.swift b/src/mobile/Muesli/Views/SimpleNoteDetailView.swift deleted file mode 100644 index 15bd430..0000000 --- a/src/mobile/Muesli/Views/SimpleNoteDetailView.swift +++ /dev/null @@ -1,663 +0,0 @@ -// -// SimpleNoteDetailView.swift -// Muesli -// -// Created by Travis Frisinger on 8/25/25. -// - -import SwiftUI -import SwiftData -import UIKit -import AVFoundation - -struct SimpleNoteDetailView: View { - let note: Note - - @Environment(\.dismiss) private var dismiss - @Environment(\.modelContext) private var modelContext - @State private var showingOptions = false - @State private var showingEditTitle = false - @State private var showingTranscript = false - @State private var showingMyNotes = false - @State private var showingEnhancedEditor = false - @State private var editedTitle = "" - @State private var showingError = false - @State private var errorMessage = "" - @State private var selectedImagePath: String? - @State private var selectedImageWrapper: ImageWrapper? - @State private var showingImagePicker = false - @State private var imagesExpanded = true - - // Audio playback state - @State private var audioPlayer: AVAudioPlayer? - @State private var isPlaying = false - @State private var playbackPosition: TimeInterval = 0 - @State private var audioDuration: TimeInterval = 0 - @State private var playbackTimer: Timer? - @State private var transcriptionTask: Task? - - var body: some View { - content - } - - private var content: some View { - NavigationStack { - ZStack { - Color.black.ignoresSafeArea() - - ScrollView { - VStack(alignment: .leading, spacing: 20) { - // Header - VStack(alignment: .leading, spacing: 8) { - Text(note.dateString) - .font(.caption) - .foregroundColor(.gray) - - Text(note.title) - .font(.title2) - .fontWeight(.bold) - .foregroundColor(.white) - } - .frame(maxWidth: .infinity, alignment: .leading) - - Divider() - .background(Color.gray.opacity(0.3)) - - // Audio player section - if note.hasAudio { - VStack(alignment: .leading, spacing: 12) { - Text("Recording") - .font(.headline) - .foregroundColor(.white) - - HStack(spacing: 16) { - // Play/Pause button - Button(action: togglePlayback) { - Image(systemName: isPlaying ? "pause.circle.fill" : "play.circle.fill") - .font(.system(size: 40)) - .foregroundColor(.teal) - } - - VStack(alignment: .leading, spacing: 4) { - // Progress bar - GeometryReader { geometry in - ZStack(alignment: .leading) { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(height: 4) - - if audioDuration > 0 { - Rectangle() - .fill(Color.teal) - .frame(width: geometry.size.width * CGFloat(playbackPosition / audioDuration), height: 4) - } - } - .cornerRadius(2) - } - .frame(height: 4) - - // Time labels - HStack { - Text(formatTime(playbackPosition)) - .font(.caption) - .foregroundColor(.gray) - Spacer() - Text(formatTime(audioDuration)) - .font(.caption) - .foregroundColor(.gray) - } - } - } - .padding(.vertical, 8) - } - .padding(.bottom, 12) - - Divider() - .background(Color.gray.opacity(0.3)) - } - - // Captured images section (collapsable) - VStack(alignment: .leading, spacing: 12) { - // Header with collapse button - Button(action: { - withAnimation { - imagesExpanded.toggle() - } - }) { - HStack { - Text("Captured Images") - .font(.headline) - .foregroundColor(.white) - - Spacer() - - Image(systemName: imagesExpanded ? "chevron.down" : "chevron.right") - .foregroundColor(.gray) - .font(.caption) - } - } - .buttonStyle(PlainButtonStyle()) - - if imagesExpanded { - // Image gallery - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 12) { - // Add new image button - Button(action: { - showingImagePicker = true - }) { - ZStack { - RoundedRectangle(cornerRadius: 8) - .fill(Color.gray.opacity(0.2)) - .frame(width: 100, height: 100) - - Image(systemName: "plus") - .font(.system(size: 32)) - .foregroundColor(.white.opacity(0.6)) - } - } - - // Existing images - ForEach(note.imagePaths, id: \.self) { imagePath in - if let image = loadImage(from: imagePath) { - ZStack(alignment: .topTrailing) { - // Image thumbnail - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: 100, height: 100) - .cornerRadius(8) - .clipped() - .onTapGesture { - if let img = loadImage(from: imagePath) { - selectedImageWrapper = ImageWrapper(image: img) - } - } - - // Delete button - Button(action: { - deleteImage(imagePath) - }) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 20)) - .foregroundColor(.white) - .background(Circle().fill(Color.black.opacity(0.5))) - } - .padding(4) - } - } - } - } - } - } - } - } - .padding(.bottom, 12) - - Divider() - .background(Color.gray.opacity(0.3)) - - // AI Summary display - Group { - if note.content.isEmpty && note.transcriptionStatus == "processing" { - // Show loading indicator while transcribing - VStack(spacing: 16) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: .teal)) - .scaleEffect(1.5) - - Text("Transcribing audio...") - .font(.subheadline) - .foregroundColor(.gray) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else if let summary = note.aiSummary, !summary.isEmpty { - // Show rendered AI-generated summary (edit via menu) - VStack(alignment: .leading, spacing: 8) { - NoteContentView(content: summary) - } - .frame(maxWidth: .infinity, alignment: .leading) - } else if !note.userNotes.isEmpty { - // Show user notes if no transcript yet - VStack(alignment: .leading, spacing: 8) { - Text("# My Notes") - .font(.headline) - .foregroundColor(.white) - .padding(.bottom, 8) - - NoteContentView(content: note.userNotes) - } - .frame(maxWidth: .infinity, alignment: .leading) - } else if note.content.isEmpty { - // Show empty state - VStack(spacing: 12) { - Image(systemName: "doc.text") - .font(.system(size: 48)) - .foregroundColor(.gray) - - Text("No transcription available") - .font(.subheadline) - .foregroundColor(.gray) - - if note.hasAudio { - Text("Tap the play button above to listen to the recording") - .font(.caption) - .foregroundColor(.gray.opacity(0.7)) - .multilineTextAlignment(.center) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 40) - } else { - // Fallback: show raw content if no summary - VStack(alignment: .leading, spacing: 8) { - NoteContentView(content: note.content) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - Spacer(minLength: 50) - } - .padding(.horizontal, 20) - .padding(.top, 20) - } - .navigationTitle("Note") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Done") { dismiss() } - .foregroundColor(.teal) - } - - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { showingOptions = true }) { - Image(systemName: "ellipsis.circle") - .foregroundColor(.white) - .font(.system(size: 20)) - } - } - } - } - .popover(isPresented: $showingOptions, attachmentAnchor: .point(.topTrailing), arrowEdge: .top) { - NoteOptionsPopover( - note: note, - onEditTitle: { - editedTitle = note.title - showingEditTitle = true - }, - onEditContent: { - showingEnhancedEditor = true - }, - onViewTranscript: { - showingTranscript = true - }, - onShowMyNotes: { - showingMyNotes = true - }, - onArchive: { - archiveNote() - }, - onDelete: { - deleteNote() - }, - onClose: { - showingOptions = false - } - ) - .presentationCompactAdaptation(.popover) - } - .sheet(isPresented: $showingTranscript) { - TranscriptView(note: note) - } - .sheet(isPresented: $showingMyNotes) { - MyNotesView(note: note) - } - .sheet(isPresented: $showingEnhancedEditor) { - EnhancedNoteEditorView(note: note) - } - .fullScreenCover(item: $selectedImageWrapper) { wrapper in - FullscreenImageViewer(image: wrapper.image, onDismiss: { - selectedImageWrapper = nil - }) - } - .sheet(isPresented: $showingImagePicker) { - ImagePicker(isPresented: $showingImagePicker, onImagePicked: { image in - addNewImage(image) - }) - } - .alert("Edit Title", isPresented: $showingEditTitle) { - TextField("Note title", text: $editedTitle) - Button("Cancel", role: .cancel) { } - Button("Save") { - saveEditedTitle() - } - .disabled(editedTitle.isEmpty) - } - .alert("Error", isPresented: $showingError) { - Button("OK") { } - } message: { - Text(errorMessage) - } - .preferredColorScheme(.dark) - .onAppear { - setupAudioPlayer() - checkAndTriggerPendingTranscription() - } - .onDisappear { - stopPlayback() - transcriptionTask?.cancel() - transcriptionTask = nil - } - } - - private func checkAndTriggerPendingTranscription() { - guard transcriptionTask == nil else { - AppLogger.shared.debug("Transcription already in flight - skipping re-trigger") - return - } - guard note.content.isEmpty else { - AppLogger.shared.debug("Note has content (\(note.content.count) chars), skipping transcription") - return - } - guard note.transcriptionStatus == "pending" else { - AppLogger.shared.debug("Note status is '\(note.transcriptionStatus)', not pending - skipping") - return - } - guard let audioPath = note.audioFilePath else { - AppLogger.shared.warning("No audio file path in note - cannot transcribe") - return - } - - AppLogger.shared.info("🎯 Note opened with pending transcription - triggering now for '\(note.title)'") - - note.transcriptionStatus = "processing" - do { - try modelContext.save() - AppLogger.shared.info("✅ Updated note status to 'processing'") - } catch { - AppLogger.shared.error("❌ Failed to update transcription status", error: error) - } - - transcriptionTask = Task { - defer { transcriptionTask = nil } - - try? await Task.sleep(nanoseconds: 500_000_000) - - guard let audioURL = AudioRecordingManager.shared.getRecordingURL(fileName: audioPath) else { - AppLogger.shared.warning("❌ Audio file not found for transcription: \(audioPath)") - await MainActor.run { - note.transcriptionStatus = "failed" - try? modelContext.save() - } - return - } - - AppLogger.shared.info("🎤 Starting transcription for audio file: \(audioURL.lastPathComponent)") - - do { - let transcript = try await HybridTranscriptionService.shared.transcribeAudioFile(url: audioURL) - AppLogger.shared.info("✅ Transcription completed: \(transcript.count) characters") - - await MainActor.run { - note.content = transcript - note.transcriptionStatus = "completed" - note.title = SimpleSummaryGenerator.generateTitle(from: transcript) - note.aiSummary = SimpleSummaryGenerator.generateSummary(from: transcript, userNotes: note.userNotes) - - do { - try modelContext.save() - AppLogger.shared.info("✅ Successfully saved transcribed note: '\(note.title)' (\(transcript.count) chars)") - } catch { - AppLogger.shared.error("❌ Failed to save transcribed content", error: error) - note.transcriptionStatus = "failed" - } - } - } catch { - AppLogger.shared.error("❌ Transcription failed on view for '\(note.title)'", error: error) - await MainActor.run { - note.transcriptionStatus = "failed" - do { - try modelContext.save() - } catch { - AppLogger.shared.error("❌ Failed to update failed status", error: error) - } - } - } - } - } - - // MARK: - Audio Playback Methods - - private func setupAudioPlayer() { - guard let audioPath = note.audioFilePath else { - AppLogger.shared.warning("No audio file path in note") - return - } - - guard let audioURL = AudioRecordingManager.shared.getRecordingURL(fileName: audioPath) else { - AppLogger.shared.warning("Audio file not found at path: \(audioPath)") - // Try to list what files ARE in the documents directory - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - if let files = try? FileManager.default.contentsOfDirectory(at: documentsPath, includingPropertiesForKeys: nil) { - AppLogger.shared.info("Files in documents directory: \(files.map { $0.lastPathComponent }.joined(separator: ", "))") - } - return - } - - AppLogger.shared.info("Loading audio from: \(audioURL.path)") - - do { - // Configure audio session for playback - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) - - audioPlayer = try AVAudioPlayer(contentsOf: audioURL) - audioPlayer?.prepareToPlay() - audioDuration = audioPlayer?.duration ?? 0 - let fileSize = (try? FileManager.default.attributesOfItem(atPath: audioURL.path)[.size] as? Int) ?? 0 - AppLogger.shared.info("Audio player loaded successfully - duration: \(audioDuration)s, file size: \(fileSize) bytes") - } catch { - AppLogger.shared.error("Failed to load audio file at \(audioURL.path)", error: error) - } - } - - private func togglePlayback() { - guard let player = audioPlayer else { return } - - if isPlaying { - player.pause() - playbackTimer?.invalidate() - playbackTimer = nil - } else { - player.play() - startPlaybackTimer() - } - - isPlaying.toggle() - } - - private func stopPlayback() { - audioPlayer?.stop() - audioPlayer = nil - playbackTimer?.invalidate() - playbackTimer = nil - isPlaying = false - playbackPosition = 0 - } - - private func startPlaybackTimer() { - playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in - guard let player = audioPlayer else { return } - - playbackPosition = player.currentTime - - if !player.isPlaying { - // Playback finished - isPlaying = false - playbackPosition = 0 - playbackTimer?.invalidate() - playbackTimer = nil - } - } - } - - private func formatTime(_ time: TimeInterval) -> String { - let minutes = Int(time) / 60 - let seconds = Int(time) % 60 - return String(format: "%d:%02d", minutes, seconds) - } - - // MARK: - Helper Methods - - private func loadImage(from path: String) -> UIImage? { - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let imageURL = documentsPath.appendingPathComponent(path) - - guard FileManager.default.fileExists(atPath: imageURL.path) else { - AppLogger.shared.warning("Image not found at path: \(path)") - return nil - } - - guard let imageData = try? Data(contentsOf: imageURL), - let image = UIImage(data: imageData) else { - AppLogger.shared.warning("Failed to load image from path: \(path)") - return nil - } - - return image - } - - private func saveEditedTitle() { - do { - note.title = editedTitle - try modelContext.save() - } catch { - showError("Failed to update note title: \(error.localizedDescription)") - } - } - - private func showError(_ message: String) { - errorMessage = message - showingError = true - } - - private func archiveNote() { - do { - note.isArchived = true - try modelContext.save() - AppLogger.shared.noteOperation(.archive, title: note.title) - AppLogger.shared.userAction("Archive Note", context: note.title) - dismiss() // Close the detail view after archiving - } catch { - showError("Failed to archive note: \(error.localizedDescription)") - } - } - - private func deleteImage(_ imagePath: String) { - // Delete file from disk - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let imageURL = documentsPath.appendingPathComponent(imagePath) - try? FileManager.default.removeItem(at: imageURL) - - // Remove from note's imagePaths array - if let index = note.imagePaths.firstIndex(of: imagePath) { - note.imagePaths.remove(at: index) - - do { - try modelContext.save() - AppLogger.shared.info("Deleted image: \(imagePath)") - } catch { - AppLogger.shared.error("Failed to save after deleting image", error: error) - } - } - } - - private func addNewImage(_ image: UIImage) { - // Save image to disk - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let imagesDirectory = documentsPath.appendingPathComponent("Images", isDirectory: true) - - do { - try FileManager.default.createDirectory(at: imagesDirectory, withIntermediateDirectories: true) - - let timestamp = Int(Date().timeIntervalSince1970) - let filename = "img_\(timestamp)_\(note.imagePaths.count).jpg" - let fileURL = imagesDirectory.appendingPathComponent(filename) - - if let imageData = image.jpegData(compressionQuality: 0.8) { - try imageData.write(to: fileURL) - // Add to note's imagePaths array - note.imagePaths.append("Images/\(filename)") - - try modelContext.save() - AppLogger.shared.info("Added new image: \(filename)") - } - } catch { - AppLogger.shared.error("Failed to add new image", error: error) - showError("Failed to add image: \(error.localizedDescription)") - } - } - - private func deleteNote() { - // Delete associated files if they exist - if let audioPath = note.audioFilePath, - let audioURL = AudioRecordingManager.shared.getRecordingURL(fileName: audioPath) { - try? FileManager.default.removeItem(at: audioURL) - AppLogger.shared.info("Deleted audio file: \(audioPath)") - } - - // Delete associated images - if !note.imagePaths.isEmpty { - let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - for imagePath in note.imagePaths { - let imageURL = documentsPath.appendingPathComponent(imagePath) - try? FileManager.default.removeItem(at: imageURL) - } - AppLogger.shared.info("Deleted \(note.imagePaths.count) image(s)") - } - - // Delete the note from the database - do { - modelContext.delete(note) - try modelContext.save() - AppLogger.shared.noteOperation(.delete, title: note.title) - AppLogger.shared.userAction("Delete Note", context: note.title) - dismiss() // Close the detail view after deletion - } catch { - showError("Failed to delete note: \(error.localizedDescription)") - } - } - -} - -// Helper struct to make UIImage identifiable for sheet(item:) -private struct ImageWrapper: Identifiable { - let id = UUID() - let image: UIImage -} - -#Preview { - let note = Note( - title: "Sample Meeting Notes", - content: """ - # Meeting Overview - - • Key discussion points covered - • Action items identified - • Follow-up meetings scheduled - - # Next Steps - - ○ Finalize project timeline - ○ Schedule stakeholder review - ○ Prepare documentation - """, - sessionType: "meeting" - ) - - SimpleNoteDetailView(note: note) - .modelContainer(for: Note.self, inMemory: true) - .environment(\.dataService, DataService(modelContext: ModelContext(try! ModelContainer(for: Note.self)))) -} diff --git a/src/mobile/Muesli/Views/TranscriptView.swift b/src/mobile/Muesli/Views/TranscriptView.swift index dab7266..a1aaf40 100644 --- a/src/mobile/Muesli/Views/TranscriptView.swift +++ b/src/mobile/Muesli/Views/TranscriptView.swift @@ -65,4 +65,4 @@ struct TranscriptView: View { AppLogger.shared.error("Failed to save transcript", error: error) } } -} \ No newline at end of file +} diff --git a/src/mobile/Muesli/World.swift b/src/mobile/Muesli/World.swift new file mode 100644 index 0000000..a22768e --- /dev/null +++ b/src/mobile/Muesli/World.swift @@ -0,0 +1,49 @@ +// +// World.swift +// Muesli +// +// Composition root for the hex-arch ports. Production sets `World.current` +// to `.live` at app launch; tests install a World composed of fake adapters +// in setUp so no test ever reaches the real network. +// + +import Foundation + +struct World { + var transcription: any TranscriptionPort + var hybridTranscription: any HybridTranscriptionPort + var network: any NetworkPort + var blend: any BlendPort + var chat: any ChatPort +} + +extension World { + /// Mutable accessor. Production is initialized to `.live` at launch and + /// never mutated thereafter. Tests overwrite this in setUp and restore + /// the prior value in tearDown. The `nonisolated(unsafe)` annotation + /// reflects this contract: writes are confined to test setUp on the main + /// actor; reads happen from any context (including detached Tasks in + /// orchestrators). Production code must NOT mutate `World.current`. + nonisolated(unsafe) static var current: World = .live + + /// Real adapters wired against production services. The chat adapter + /// uses a default-empty sessionIdsResolver; ChatViewModel pre-resolves + /// the conference's member sessions from SwiftData and passes them via + /// LiveChatAdapter's explicit-resolver send variant. + /// + /// Auth is NOT added to chat requests; SessionsService matches this and + /// the backend's requireAuth middleware no-ops when AUTH_ENABLED=false. + /// Wiring access tokens here is a follow-on across all live adapters. + static var live: World { + // APIConfiguration.baseURL is the bare host (no /api/v1) — the live + // chat routes are mounted at /v1/sessions/:id/chat and /v1/chat so + // the adapter appends them itself. + return World( + transcription: TranscriptionService.shared, + hybridTranscription: HybridTranscriptionService.shared, + network: NetworkMonitor.shared, + blend: SessionsService.shared, + chat: LiveChatAdapter(baseURL: APIConfiguration.baseURL) + ) + } +} diff --git a/src/mobile/MuesliRecordingLiveActivity/Info.plist b/src/mobile/MuesliRecordingLiveActivity/Info.plist new file mode 100644 index 0000000..0b38f6b --- /dev/null +++ b/src/mobile/MuesliRecordingLiveActivity/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Muesli Recording + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSSupportsLiveActivities + + + diff --git a/src/mobile/MuesliRecordingLiveActivity/MuesliRecordingLiveActivityBundle.swift b/src/mobile/MuesliRecordingLiveActivity/MuesliRecordingLiveActivityBundle.swift new file mode 100644 index 0000000..b9e086d --- /dev/null +++ b/src/mobile/MuesliRecordingLiveActivity/MuesliRecordingLiveActivityBundle.swift @@ -0,0 +1,18 @@ +// +// MuesliRecordingLiveActivityBundle.swift +// MuesliRecordingLiveActivity +// +// Widget extension entry point. Hosts the Live Activity for the +// recording flow; the host app starts/updates/ends it via +// LiveActivityController. +// + +import WidgetKit +import SwiftUI + +@main +struct MuesliRecordingLiveActivityBundle: WidgetBundle { + var body: some Widget { + RecordingActivityWidget() + } +} diff --git a/src/mobile/MuesliRecordingLiveActivity/RecordingActivityWidget.swift b/src/mobile/MuesliRecordingLiveActivity/RecordingActivityWidget.swift new file mode 100644 index 0000000..fc70c93 --- /dev/null +++ b/src/mobile/MuesliRecordingLiveActivity/RecordingActivityWidget.swift @@ -0,0 +1,75 @@ +// +// RecordingActivityWidget.swift +// MuesliRecordingLiveActivity +// +// Live Activity UI for an in-progress recording. Lock screen shows +// the talk title + elapsed time; Dynamic Island shows compact +// red-dot + mm:ss and an expanded title + elapsed + paused status. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +struct RecordingActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: RecordingActivityAttributes.self) { context in + // Lock-screen / banner UI. + HStack(spacing: 12) { + Image(systemName: context.state.isPaused ? "pause.circle.fill" : "record.circle") + .foregroundStyle(.red) + .font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text(context.attributes.title) + .font(.headline) + Text(formatTime(context.state.elapsedSeconds)) + .font(.subheadline.monospacedDigit()) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding() + .activityBackgroundTint(Color.black.opacity(0.85)) + .activitySystemActionForegroundColor(.white) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Image(systemName: context.state.isPaused ? "pause.circle.fill" : "record.circle") + .foregroundStyle(.red) + } + DynamicIslandExpandedRegion(.trailing) { + Text(formatTime(context.state.elapsedSeconds)) + .font(.subheadline.monospacedDigit()) + } + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.bottom) { + Text(context.state.isPaused ? "Paused" : "Recording") + .font(.caption) + .foregroundStyle(.secondary) + } + } compactLeading: { + Image(systemName: context.state.isPaused ? "pause.fill" : "record.circle") + .foregroundStyle(.red) + } compactTrailing: { + Text(formatTime(context.state.elapsedSeconds)) + .font(.caption.monospacedDigit()) + } minimal: { + Image(systemName: "record.circle") + .foregroundStyle(.red) + } + } + } + + private func formatTime(_ seconds: Int) -> String { + let h = seconds / 3600 + let m = (seconds % 3600) / 60 + let s = seconds % 60 + return h > 0 + ? String(format: "%d:%02d:%02d", h, m, s) + : String(format: "%02d:%02d", m, s) + } +} diff --git a/src/mobile/MuesliTests/Adapters/LiveChatAdapterTests.swift b/src/mobile/MuesliTests/Adapters/LiveChatAdapterTests.swift new file mode 100644 index 0000000..46ffca4 --- /dev/null +++ b/src/mobile/MuesliTests/Adapters/LiveChatAdapterTests.swift @@ -0,0 +1,134 @@ +// +// LiveChatAdapterTests.swift +// MuesliTests +// + +import Testing +import Foundation +@testable import Muesli + +@Suite("Live Chat Adapter Tests", .tags(.unit)) +struct LiveChatAdapterTests { + final class StubProtocol: URLProtocol { + nonisolated(unsafe) static var lastRequest: URLRequest? + nonisolated(unsafe) static var lastBody: Data? + nonisolated(unsafe) static var responseBody: Data? + nonisolated(unsafe) static var status: Int = 200 + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + StubProtocol.lastRequest = request + // URLSession routes httpBody through httpBodyStream; capture it. + if let stream = request.httpBodyStream { + stream.open() + var data = Data() + let bufferSize = 1_024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { + buffer.deallocate() + stream.close() + } + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + StubProtocol.lastBody = data + } else { + StubProtocol.lastBody = request.httpBody + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: StubProtocol.status, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let body = StubProtocol.responseBody { + client?.urlProtocol(self, didLoad: body) + } + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} + } + + private func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + @Test("sends talk-scope POST to /v1/sessions/:id/chat with messages body") + func talkScopeRequest() async throws { + let noteId = UUID() + StubProtocol.status = 200 + StubProtocol.responseBody = """ + {"message":{"role":"assistant","content":"Hi"},"citations":[],"usage":{"tokensIn":1,"tokensOut":1}} + """.data(using: .utf8) + let adapter = LiveChatAdapter(baseURL: URL(string: "https://api.example.com")!, session: makeSession()) + let resp = try await adapter.send( + scope: .talk(noteId), + messages: [ChatTurn(role: "user", content: "hi")] + ) + #expect(resp.message.content == "Hi") + let req = try #require(StubProtocol.lastRequest) + #expect(req.httpMethod == "POST") + #expect(req.url?.path == "/v1/sessions/\(noteId.uuidString)/chat") + let body = try #require(StubProtocol.lastBody) + let decoded = try JSONDecoder().decode([String: [ChatTurn]].self, from: body) + #expect(decoded["messages"]?.first?.content == "hi") + } + + @Test("sends conference-scope POST to /v1/chat with sessionIds + messages body") + func conferenceScopeRequest() async throws { + let confId = UUID() + let sessions = [UUID(), UUID()] + StubProtocol.status = 200 + StubProtocol.responseBody = """ + {"message":{"role":"assistant","content":"Hi"},"citations":[],"usage":{"tokensIn":0,"tokensOut":0}} + """.data(using: .utf8) + let adapter = LiveChatAdapter(baseURL: URL(string: "https://api.example.com")!, session: makeSession()) + _ = try await adapter.send( + scope: .conference(confId), + messages: [ChatTurn(role: "user", content: "hi")], + sessionIdsResolver: { _ in sessions } + ) + let req = try #require(StubProtocol.lastRequest) + #expect(req.url?.path == "/v1/chat") + let body = try #require(StubProtocol.lastBody) + struct Body: Decodable { let sessionIds: [UUID]; let messages: [ChatTurn] } + let decoded = try JSONDecoder().decode(Body.self, from: body) + #expect(decoded.sessionIds == sessions) + } + + @Test("decodes citations correctly") + func decodesCitations() async throws { + let talkId = UUID() + StubProtocol.status = 200 + StubProtocol.responseBody = """ + {"message":{"role":"assistant","content":"see"}, + "citations":[ + {"kind":"transcript","talkId":"\(talkId.uuidString)","startSec":12.4,"endSec":24.1,"label":"00:12"}, + {"kind":"note","noteId":"\(talkId.uuidString)","title":"T"} + ], + "usage":{"tokensIn":0,"tokensOut":0}} + """.data(using: .utf8) + let adapter = LiveChatAdapter(baseURL: URL(string: "https://api.example.com")!, session: makeSession()) + let resp = try await adapter.send(scope: .talk(talkId), messages: [ChatTurn(role: "user", content: "?")]) + #expect(resp.citations.count == 2) + #expect(resp.citations[0].kind == .transcript) + #expect(resp.citations[1].kind == .note) + } + + @Test("throws on non-2xx response") + func throwsOnError() async throws { + StubProtocol.status = 502 + StubProtocol.responseBody = #"{"error":"chat_failed"}"#.data(using: .utf8) + let adapter = LiveChatAdapter(baseURL: URL(string: "https://api.example.com")!, session: makeSession()) + await #expect(throws: Error.self) { + _ = try await adapter.send(scope: .talk(UUID()), messages: [ChatTurn(role: "user", content: "?")]) + } + StubProtocol.status = 200 + } +} diff --git a/src/mobile/MuesliTests/ContentParsing/ContentParsingTests.swift b/src/mobile/MuesliTests/ContentParsing/ContentParsingTests.swift index c77a4ec..4f2e05f 100644 --- a/src/mobile/MuesliTests/ContentParsing/ContentParsingTests.swift +++ b/src/mobile/MuesliTests/ContentParsing/ContentParsingTests.swift @@ -11,54 +11,53 @@ import Foundation @Suite("Content Parsing Tests", .tags(.contentParsing)) struct ContentParsingTests { - @Test("Parse content handles headers correctly") func parseContentHandlesHeaders() async throws { let content = "# Header 1\n# Header 2" let parsed = ContentUtilities.parseContent(content) - + let headers = parsed.filter { $0.1 == .header } #expect(headers.count == 2) #expect(headers[0].0 == "Header 1") #expect(headers[1].0 == "Header 2") } - + @Test("Parse content handles bullet points") func parseContentHandlesBullets() async throws { let content = "• Bullet 1\n• Bullet 2" let parsed = ContentUtilities.parseContent(content) - + let bullets = parsed.filter { $0.1 == .bullet } #expect(bullets.count == 2) #expect(bullets[0].0 == "Bullet 1") #expect(bullets[1].0 == "Bullet 2") } - + @Test("Parse content handles sub-bullets") func parseContentHandlesSubBullets() async throws { let content = "• Main bullet\n○ Sub bullet 1\n○ Sub bullet 2" let parsed = ContentUtilities.parseContent(content) - + let mainBullets = parsed.filter { $0.1 == .bullet } let subBullets = parsed.filter { $0.1 == .subBullet } - + #expect(mainBullets.count == 1) #expect(subBullets.count == 2) #expect(mainBullets[0].0 == "Main bullet") #expect(subBullets[0].0 == "Sub bullet 1") #expect(subBullets[1].0 == "Sub bullet 2") } - + @Test("Parse content handles regular text") func parseContentHandlesText() async throws { let content = "Regular text line" let parsed = ContentUtilities.parseContent(content) - + #expect(parsed.count == 1) #expect(parsed[0].1 == .bullet) // Regular text is treated as bullet in the actual implementation #expect(parsed[0].0 == "Regular text line") } - + @Test("Parse content handles mixed content types") func parseContentHandlesMixedContent() async throws { let content = """ @@ -68,9 +67,9 @@ struct ContentParsingTests { ○ Sub bullet More text """ - + let parsed = ContentUtilities.parseContent(content) - + #expect(parsed.count == 5) #expect(parsed[0].1 == .header) #expect(parsed[1].1 == .bullet) @@ -78,22 +77,22 @@ struct ContentParsingTests { #expect(parsed[3].1 == .subBullet) #expect(parsed[4].1 == .bullet) } - + @Test("Parse content ignores empty lines") func parseContentIgnoresEmptyLines() async throws { let content = "Line 1\n\n\nLine 2\n\n" let parsed = ContentUtilities.parseContent(content) - + #expect(parsed.count == 2) #expect(parsed[0].0 == "Line 1") #expect(parsed[1].0 == "Line 2") } - + @Test("Parse content handles special characters") func parseContentHandlesSpecialCharacters() async throws { let content = "# Header with émojis 🚀\n• Bullet with special chars: @#$%" let parsed = ContentUtilities.parseContent(content) - + #expect(parsed.count == 2) #expect(parsed[0].0.contains("Header with émojis 🚀")) #expect(parsed[1].0.contains("Bullet with special chars: @#$%")) diff --git a/src/mobile/MuesliTests/Fakes/FakeBlendAdapter.swift b/src/mobile/MuesliTests/Fakes/FakeBlendAdapter.swift new file mode 100644 index 0000000..7acb916 --- /dev/null +++ b/src/mobile/MuesliTests/Fakes/FakeBlendAdapter.swift @@ -0,0 +1,48 @@ +// +// FakeBlendAdapter.swift +// MuesliTests +// +// In-memory blend adapter for tests. Records calls and returns canned +// BlendResponse / PhotoResponse data. +// + +import Foundation +@testable import Muesli + +actor FakeBlendAdapter: BlendPort { + var stubSessionId = UUID() + var stubPhotoResponse = PhotoResponse(photoId: "fake", ocrText: "", description: "") + var stubBlendResponse = BlendResponse( + blendedMarkdown: "Fake blend", + userNoteSpans: [], + quoteSpans: [], + imagePlacements: [], + citations: [], + chapters: [], + costMicros: 0 + ) + + private(set) var createSessionCount = 0 + private(set) var uploadAudioCount = 0 + private(set) var uploadPhotoCount = 0 + private(set) var runBlendCount = 0 + + func createSession() async throws -> UUID { + createSessionCount += 1 + return stubSessionId + } + + func uploadAudio(sessionId: UUID, audioURL: URL, durationSeconds: Double) async throws { + uploadAudioCount += 1 + } + + func uploadPhoto(sessionId: UUID, upload: PhotoUpload) async throws -> PhotoResponse { + uploadPhotoCount += 1 + return stubPhotoResponse + } + + func runBlend(sessionId: UUID, userNotes: String) async throws -> BlendResponse { + runBlendCount += 1 + return stubBlendResponse + } +} diff --git a/src/mobile/MuesliTests/Fakes/FakeHybridTranscriptionAdapter.swift b/src/mobile/MuesliTests/Fakes/FakeHybridTranscriptionAdapter.swift new file mode 100644 index 0000000..fe54575 --- /dev/null +++ b/src/mobile/MuesliTests/Fakes/FakeHybridTranscriptionAdapter.swift @@ -0,0 +1,24 @@ +// +// FakeHybridTranscriptionAdapter.swift +// MuesliTests +// +// Test adapter for file (batch) transcription. Returns a canned string +// or throws a stub error so tests never reach a real backend. +// + +import Foundation +@testable import Muesli + +final class FakeHybridTranscriptionAdapter: HybridTranscriptionPort { + var stubTranscript: String = "fake transcript" + var stubError: Error? + private(set) var transcribeURLs: [URL] = [] + + func transcribeAudioFile(url: URL) async throws -> String { + transcribeURLs.append(url) + if let stubError { + throw stubError + } + return stubTranscript + } +} diff --git a/src/mobile/MuesliTests/Fakes/FakeNetworkAdapter.swift b/src/mobile/MuesliTests/Fakes/FakeNetworkAdapter.swift new file mode 100644 index 0000000..ed9e1ae --- /dev/null +++ b/src/mobile/MuesliTests/Fakes/FakeNetworkAdapter.swift @@ -0,0 +1,21 @@ +// +// FakeNetworkAdapter.swift +// MuesliTests +// +// In-memory network adapter for tests. Defaults to disconnected so +// no test code path tries to reach a real host. +// + +import Foundation +@testable import Muesli + +final class FakeNetworkAdapter: NetworkPort { + var stubIsConnected: Bool = false + private(set) var startMonitoringCount = 0 + private(set) var stopMonitoringCount = 0 + + var isConnected: Bool { stubIsConnected } + + func startMonitoring() { startMonitoringCount += 1 } + func stopMonitoring() { stopMonitoringCount += 1 } +} diff --git a/src/mobile/MuesliTests/Fakes/FakeTranscriptionAdapter.swift b/src/mobile/MuesliTests/Fakes/FakeTranscriptionAdapter.swift new file mode 100644 index 0000000..9ed799f --- /dev/null +++ b/src/mobile/MuesliTests/Fakes/FakeTranscriptionAdapter.swift @@ -0,0 +1,50 @@ +// +// FakeTranscriptionAdapter.swift +// MuesliTests +// +// In-memory transcription adapter for tests. Never hits the network. +// Records start/stop calls so tests can assert on behavior. +// + +import Foundation +@testable import Muesli + +final class FakeTranscriptionAdapter: TranscriptionPort { + // Configurable per-test + var stubHasValidEndpoint: Bool = true + var stubStartReturns: Bool = false + var stubFileTranscript: String? + + // Recorded calls + private(set) var startCount = 0 + private(set) var stopCount = 0 + private(set) var transcribeFileURLs: [URL] = [] + + // Port surface + var isTranscribing: Bool = false + var hasValidAPIEndpoint: Bool { stubHasValidEndpoint } + var environmentName: String { "test" } + var currentAPIEndpoint: String { "https://test.local" } + var isUsingLocalhost: Bool { false } + + var onError: ((Error) -> Void)? + var onTranscriptionUpdate: ((TranscriptionResult) -> Void)? + + func startRealtimeTranscription() async -> Bool { + startCount += 1 + if stubStartReturns { + isTranscribing = true + } + return stubStartReturns + } + + func stopRealtimeTranscription() { + stopCount += 1 + isTranscribing = false + } + + func transcribeAudioFile(url: URL) async -> String? { + transcribeFileURLs.append(url) + return stubFileTranscript + } +} diff --git a/src/mobile/MuesliTests/Fakes/TestWorld.swift b/src/mobile/MuesliTests/Fakes/TestWorld.swift new file mode 100644 index 0000000..bb732c5 --- /dev/null +++ b/src/mobile/MuesliTests/Fakes/TestWorld.swift @@ -0,0 +1,38 @@ +// +// TestWorld.swift +// MuesliTests +// +// Installs a fully-faked World.current so no test reaches real network. +// Tests can either call TestWorld.install() in setUp, or build their own +// World composed of specific fakes via TestWorld.make(). +// + +import Foundation +@testable import Muesli + +enum TestWorld { + /// Replace World.current with a fully-faked World. Returns the fakes + /// so the test can configure stubs and inspect recorded calls. + @discardableResult + static func install( + transcription: FakeTranscriptionAdapter = FakeTranscriptionAdapter(), + hybridTranscription: any HybridTranscriptionPort = FakeHybridTranscriptionAdapter(), + network: FakeNetworkAdapter = FakeNetworkAdapter(), + blend: FakeBlendAdapter = FakeBlendAdapter(), + chat: any ChatPort = UnimplementedChatAdapter() + ) -> (transcription: FakeTranscriptionAdapter, network: FakeNetworkAdapter, blend: FakeBlendAdapter) { + World.current = World( + transcription: transcription, + hybridTranscription: hybridTranscription, + network: network, + blend: blend, + chat: chat + ) + return (transcription, network, blend) + } + + /// Restore the live World (used in tearDown). + static func restore() { + World.current = .live + } +} diff --git a/src/mobile/MuesliTests/Models/ChatThreadModelTests.swift b/src/mobile/MuesliTests/Models/ChatThreadModelTests.swift new file mode 100644 index 0000000..cdb2eda --- /dev/null +++ b/src/mobile/MuesliTests/Models/ChatThreadModelTests.swift @@ -0,0 +1,75 @@ +// +// ChatThreadModelTests.swift +// MuesliTests +// +// Unit tests for the ChatThread and ChatMessage SwiftData entities. +// + +import Testing +import SwiftData +import Foundation +@testable import Muesli + +@Suite("Chat Thread Model Tests", .tags(.unit)) +struct ChatThreadModelTests { + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test("ChatThread initializes with talk scope") + func chatThreadTalkScope() async throws { + let noteId = UUID() + let thread = ChatThread(scopeKind: .talk, scopeId: noteId) + + #expect(thread.scopeKind == .talk) + #expect(thread.scopeId == noteId) + #expect(thread.messages.isEmpty) + #expect(thread.createdAt.timeIntervalSinceNow < 1) + #expect(thread.updatedAt.timeIntervalSinceNow < 1) + } + + @Test("ChatThread initializes with conference scope") + func chatThreadConferenceScope() async throws { + let confId = UUID() + let thread = ChatThread(scopeKind: .conference, scopeId: confId) + + #expect(thread.scopeKind == .conference) + #expect(thread.scopeId == confId) + } + + @Test("ChatMessage initializes with role and content") + func chatMessageInit() async throws { + let msg = ChatMessage(role: .user, content: "Hello") + + #expect(msg.role == .user) + #expect(msg.content == "Hello") + #expect(msg.citationsJSON == nil) + #expect(msg.thread == nil) + } + + @Test("ChatThread cascade-deletes messages") + func chatThreadCascadeDeletes() async throws { + let container = try makeContainer() + let context = ModelContext(container) + + let thread = ChatThread(scopeKind: .talk, scopeId: UUID()) + let msg1 = ChatMessage(role: .user, content: "Q") + let msg2 = ChatMessage(role: .assistant, content: "A") + thread.messages = [msg1, msg2] + msg1.thread = thread + msg2.thread = thread + + context.insert(thread) + context.insert(msg1) + context.insert(msg2) + try context.save() + + context.delete(thread) + try context.save() + + let remaining = try context.fetch(FetchDescriptor()) + #expect(remaining.isEmpty) + } +} diff --git a/src/mobile/MuesliTests/Models/ConferenceMigrationTests.swift b/src/mobile/MuesliTests/Models/ConferenceMigrationTests.swift new file mode 100644 index 0000000..1602425 --- /dev/null +++ b/src/mobile/MuesliTests/Models/ConferenceMigrationTests.swift @@ -0,0 +1,117 @@ +// +// ConferenceMigrationTests.swift +// MuesliTests +// +// Tests the one-time backfill from Note.conferenceName strings into +// Conference records with attached note relationships. +// + +import XCTest +import SwiftData +@testable import Muesli + +@MainActor +final class ConferenceMigrationTests: XCTestCase { + override func setUp() { + super.setUp() + UserDefaults.standard.removeObject(forKey: ConferenceMigration.runFlagKey) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: ConferenceMigration.runFlagKey) + super.tearDown() + } + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + func testGroupsNotesByConferenceName() throws { + let container = try makeContainer() + let context = ModelContext(container) + + let n1 = Note(title: "Talk A", timestamp: Date(timeIntervalSince1970: 1_000), conferenceName: "DataSummit 2026") + let n2 = Note(title: "Talk B", timestamp: Date(timeIntervalSince1970: 2_000), conferenceName: "DataSummit 2026") + let n3 = Note(title: "Solo", timestamp: Date(timeIntervalSince1970: 3_000), conferenceName: "DevWorld") + let n4 = Note(title: "Loose", timestamp: Date(timeIntervalSince1970: 4_000), conferenceName: nil) + [n1, n2, n3, n4].forEach { context.insert($0) } + try context.save() + + ConferenceMigration.run(in: context) + + let confs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(confs.count, 2) + + let summit = confs.first { $0.name == "DataSummit 2026" } + XCTAssertNotNil(summit) + XCTAssertEqual(summit?.notes.count, 2) + XCTAssertEqual(summit?.startDate, Date(timeIntervalSince1970: 1_000)) + XCTAssertEqual(summit?.endDate, Date(timeIntervalSince1970: 2_000)) + + let dev = confs.first { $0.name == "DevWorld" } + XCTAssertEqual(dev?.notes.count, 1) + + XCTAssertNil(n4.conference) + } + + func testIsIdempotent() throws { + let container = try makeContainer() + let context = ModelContext(container) + + let n1 = Note(title: "A", timestamp: Date(timeIntervalSince1970: 1_000), conferenceName: "DataSummit 2026") + context.insert(n1) + try context.save() + + ConferenceMigration.run(in: context) + ConferenceMigration.run(in: context) + + let confs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(confs.count, 1, "Running migration twice must not create duplicates") + XCTAssertEqual(confs.first?.notes.count, 1) + } + + func testCaseInsensitiveAndTrimmedGrouping() throws { + let container = try makeContainer() + let context = ModelContext(container) + + let a = Note(title: "A", conferenceName: "DataSummit 2026") + let b = Note(title: "B", conferenceName: "datasummit 2026") + let c = Note(title: "C", conferenceName: " DataSummit 2026 ") + [a, b, c].forEach { context.insert($0) } + try context.save() + + ConferenceMigration.run(in: context) + + let confs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(confs.count, 1, "Names differing only by case or whitespace must group") + XCTAssertEqual(confs.first?.notes.count, 3) + } + + func testSkipsNotesAlreadyAttached() throws { + let container = try makeContainer() + let context = ModelContext(container) + + let existing = Conference(name: "DataSummit 2026") + let n = Note(title: "Pre-attached", conferenceName: "DataSummit 2026") + n.conference = existing + context.insert(existing) + context.insert(n) + try context.save() + + ConferenceMigration.run(in: context) + + let confs = try context.fetch(FetchDescriptor()) + XCTAssertEqual(confs.count, 1, "Existing Conference must be reused, not duplicated") + XCTAssertEqual(confs.first?.notes.count, 1) + } + + func testHasRunFlagSet() throws { + let container = try makeContainer() + let context = ModelContext(container) + XCTAssertFalse(ConferenceMigration.hasRun) + ConferenceMigration.run(in: context) + XCTAssertTrue(ConferenceMigration.hasRun) + } +} diff --git a/src/mobile/MuesliTests/Models/ConferenceModelTests.swift b/src/mobile/MuesliTests/Models/ConferenceModelTests.swift new file mode 100644 index 0000000..f299ebe --- /dev/null +++ b/src/mobile/MuesliTests/Models/ConferenceModelTests.swift @@ -0,0 +1,52 @@ +// +// ConferenceModelTests.swift +// MuesliTests +// +// Unit tests for the Conference SwiftData entity. +// + +import Testing +import SwiftData +import Foundation +@testable import Muesli + +@Suite("Conference Model Tests", .tags(.unit)) +struct ConferenceModelTests { + @Test("Conference initialization with required fields") + func conferenceInitialization() async throws { + let conf = Conference(name: "DataSummit 2026") + + #expect(conf.name == "DataSummit 2026") + #expect(conf.location == nil) + #expect(conf.startDate == nil) + #expect(conf.endDate == nil) + #expect(conf.conferenceDescription == nil) + #expect(conf.notes.isEmpty) + #expect(conf.createdAt.timeIntervalSinceNow < 1) + } + + @Test("Conference initialization with all metadata") + func conferenceFullInit() async throws { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let end = Date(timeIntervalSince1970: 1_700_200_000) + let conf = Conference( + name: "DataSummit 2026", + location: "San Francisco", + startDate: start, + endDate: end, + conferenceDescription: "Annual data conference" + ) + + #expect(conf.location == "San Francisco") + #expect(conf.startDate == start) + #expect(conf.endDate == end) + #expect(conf.conferenceDescription == "Annual data conference") + } + + @Test("Conference has stable UUID") + func conferenceStableID() async throws { + let id = UUID() + let conf = Conference(id: id, name: "X") + #expect(conf.id == id) + } +} diff --git a/src/mobile/MuesliTests/Models/NoteModelTests.swift b/src/mobile/MuesliTests/Models/NoteModelTests.swift index b328a43..a71e1fd 100644 --- a/src/mobile/MuesliTests/Models/NoteModelTests.swift +++ b/src/mobile/MuesliTests/Models/NoteModelTests.swift @@ -12,21 +12,20 @@ import Foundation @Suite("Note Model Tests", .tags(.unit)) struct NoteModelTests { - @Test("Note initialization with all properties") func noteInitialization() async throws { let title = "Test Meeting" let content = "This is test content" let conferenceName = "TestConf 2024" let sessionType = "Keynote" - + let note = Note( title: title, content: content, conferenceName: conferenceName, sessionType: sessionType ) - + #expect(note.title == title) #expect(note.content == content) #expect(note.conferenceName == conferenceName) @@ -34,7 +33,7 @@ struct NoteModelTests { #expect(note.isArchived == false) // Default value #expect(note.timestamp.timeIntervalSinceNow < 1) // Created recently } - + @Test("Note userNotes defaults to empty string") func noteUserNotesDefault() async throws { let note = Note( @@ -86,11 +85,11 @@ struct NoteModelTests { note.isArchived = true #expect(note.isArchived == true) - + note.isArchived = false #expect(note.isArchived == false) } - + @Test("Note time string formatting") func noteTimeString() async throws { let note = Note( @@ -99,13 +98,13 @@ struct NoteModelTests { conferenceName: "Conf", sessionType: "Session" ) - + let timeString = note.timeString #expect(!timeString.isEmpty) // Should contain either AM or PM #expect(timeString.contains("AM") || timeString.contains("PM")) } - + @Test("Note date string formatting") func noteDateString() async throws { let note = Note( @@ -114,14 +113,14 @@ struct NoteModelTests { conferenceName: "Conf", sessionType: "Session" ) - + let dateString = note.dateString #expect(!dateString.isEmpty) // Should contain current year let currentYear = Calendar.current.component(.year, from: Date()) #expect(dateString.contains(String(currentYear))) } - + @Test("Note handles unicode content") func noteHandlesUnicodeContent() async throws { let unicodeContent = "Test with émojis 🚀 and spëcial characters" @@ -131,25 +130,25 @@ struct NoteModelTests { conferenceName: "Unicode Conf", sessionType: "Testing" ) - + #expect(note.content == unicodeContent) #expect(note.title == "Unicode Test") } - + @Test("Note handles long content") func noteHandlesLongContent() async throws { - let longContent = String(repeating: "This is a very long content string. ", count: 1000) + let longContent = String(repeating: "This is a very long content string. ", count: 1_000) let note = Note( title: "Long Content Test", content: longContent, conferenceName: "Performance Conf", sessionType: "Load Testing" ) - + #expect(note.content == longContent) - #expect(note.content.count > 30000) // Ensure it's actually long + #expect(note.content.count > 30_000) // Ensure it's actually long } - + @Test("Note audio properties work correctly") func noteAudioPropertiesWorkCorrectly() async throws { let noteWithAudio = Note( @@ -159,24 +158,24 @@ struct NoteModelTests { transcriptionStatus: "completed", duration: 120.5 ) - + #expect(noteWithAudio.hasAudio == true) #expect(noteWithAudio.audioFilePath == "recording_123.m4a") #expect(noteWithAudio.transcriptionStatus == "completed") #expect(noteWithAudio.duration == 120.5) #expect(noteWithAudio.durationString == "02:00") - + let noteWithoutAudio = Note( title: "Text Note", content: "This note has no audio" ) - + #expect(noteWithoutAudio.hasAudio == false) #expect(noteWithoutAudio.audioFilePath == nil) #expect(noteWithoutAudio.transcriptionStatus == "none") - #expect(noteWithoutAudio.duration == 0) + #expect(noteWithoutAudio.duration == nil) } - + @Test("Note transcription status properties work correctly") func noteTranscriptionStatusPropertiesWorkCorrectly() async throws { let needsTranscriptionNote = Note( @@ -185,50 +184,50 @@ struct NoteModelTests { audioFilePath: "recording.m4a", transcriptionStatus: "none" ) - + #expect(needsTranscriptionNote.needsTranscription == true) #expect(needsTranscriptionNote.isTranscribing == false) - + let failedTranscriptionNote = Note( title: "Failed Note", content: "Content", audioFilePath: "recording.m4a", transcriptionStatus: "failed" ) - + #expect(failedTranscriptionNote.needsTranscription == true) #expect(failedTranscriptionNote.isTranscribing == false) - + let processingNote = Note( title: "Processing Note", content: "Content", audioFilePath: "recording.m4a", transcriptionStatus: "processing" ) - + #expect(processingNote.needsTranscription == false) #expect(processingNote.isTranscribing == true) - + let completedNote = Note( title: "Completed Note", content: "Transcribed content", audioFilePath: "recording.m4a", transcriptionStatus: "completed" ) - + #expect(completedNote.needsTranscription == false) #expect(completedNote.isTranscribing == false) - + let noAudioNote = Note( title: "Text Only", content: "No audio file", transcriptionStatus: "none" ) - + #expect(noAudioNote.needsTranscription == false) #expect(noAudioNote.isTranscribing == false) } - + @Test("Note duration formatting works correctly") func noteDurationFormattingWorksCorrectly() async throws { let testCases: [(TimeInterval, String)] = [ @@ -236,21 +235,58 @@ struct NoteModelTests { (30, "00:30"), (60, "01:00"), (90, "01:30"), - (3600, "60:00"), - (3661, "61:01"), + (3_600, "60:00"), + (3_661, "61:01"), (120.7, "02:00") // Should truncate fractional seconds ] - + for (duration, expected) in testCases { let note = Note( title: "Duration Test", content: "Test content", duration: duration ) - + #expect(note.durationString == expected) } } + + @Test("Note speaker defaults to nil") + func noteSpeakerDefault() async throws { + let note = Note(title: "Talk") + #expect(note.speaker == nil) + } + + @Test("Note speaker can be set") + func noteSpeakerSet() async throws { + let note = Note(title: "Talk", speaker: "Sarah Chen") + #expect(note.speaker == "Sarah Chen") + } + + @Test("Note conference relationship is nil by default") + func noteConferenceDefault() async throws { + let note = Note(title: "Talk") + #expect(note.conference == nil) + } + + @Test("Note can be attached to Conference") + func noteConferenceRelationship() async throws { + let schema = Schema([Note.self, Photo.self, Conference.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [config]) + let context = ModelContext(container) + + let conf = Conference(name: "DataSummit 2026") + let note = Note(title: "Talk") + note.conference = conf + context.insert(conf) + context.insert(note) + try context.save() + + #expect(note.conference?.name == "DataSummit 2026") + #expect(conf.notes.count == 1) + #expect(conf.notes.first?.title == "Talk") + } } // MARK: - Test Tags diff --git a/src/mobile/MuesliTests/Models/PhotoMigrationTests.swift b/src/mobile/MuesliTests/Models/PhotoMigrationTests.swift index a43ff74..b44e4c1 100644 --- a/src/mobile/MuesliTests/Models/PhotoMigrationTests.swift +++ b/src/mobile/MuesliTests/Models/PhotoMigrationTests.swift @@ -11,7 +11,6 @@ import SwiftData @MainActor final class PhotoMigrationTests: XCTestCase { - private func makeContainer() throws -> ModelContainer { let schema = Schema([Note.self, Photo.self]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) diff --git a/src/mobile/MuesliTests/MuesliTests.swift b/src/mobile/MuesliTests/MuesliTests.swift index 5c4c90d..914376f 100644 --- a/src/mobile/MuesliTests/MuesliTests.swift +++ b/src/mobile/MuesliTests/MuesliTests.swift @@ -17,4 +17,4 @@ import Testing @testable import Muesli // This is a placeholder file that demonstrates the modular test organization. -// All actual tests are in their respective category files. \ No newline at end of file +// All actual tests are in their respective category files. diff --git a/src/mobile/MuesliTests/SampleData/SampleDataTests.swift b/src/mobile/MuesliTests/SampleData/SampleDataTests.swift index 1d842bb..4082f6f 100644 --- a/src/mobile/MuesliTests/SampleData/SampleDataTests.swift +++ b/src/mobile/MuesliTests/SampleData/SampleDataTests.swift @@ -11,38 +11,37 @@ import Foundation @Suite("Content Utilities Tests", .tags(.contentUtilities)) struct ContentUtilitiesTests { - @Test("Parse content produces valid structure") func parseContentProducesValidStructure() async throws { let sampleText = "This is a test meeting transcript with various content." let content = ContentUtilities.parseContent(sampleText) - + #expect(!content.isEmpty) // parseContent should return structured content - #expect(content.count > 0) + #expect(!content.isEmpty) } - + @Test("Extract personal notes from content") func extractPersonalNotesFromContent() async throws { let transcript = ContentUtilities.sampleTranscript let personalNotes = ContentUtilities.extractPersonalNotes(from: transcript) - - // Should extract meaningful content from action items - #expect(personalNotes.count >= 0) // At least no errors in extraction - // Check that it can process the transcript without issues + + // The sample transcript embeds "Action items:" which the extractor + // surfaces as a personal note. Either the parser finds it, or it + // returns nothing (no errors) — both are acceptable shapes. let hasActionContent = personalNotes.contains { $0.contains("Action items") } - #expect(hasActionContent || personalNotes.count >= 0) // Either finds action items or processes correctly + #expect(hasActionContent || personalNotes.isEmpty) } - + @Test("Sample transcript is not empty") func sampleTranscriptNotEmpty() async throws { #expect(!ContentUtilities.sampleTranscript.isEmpty) } - + @Test("Sample transcript contains expected content") func sampleTranscriptContainsExpectedContent() async throws { let transcript = ContentUtilities.sampleTranscript - + // Should contain meeting-like content #expect(transcript.contains("meeting") || transcript.contains("Meeting")) // Should have substantial content diff --git a/src/mobile/MuesliTests/Services/SessionsClientTests.swift b/src/mobile/MuesliTests/Services/SessionsClientTests.swift index 45cafea..fba17f6 100644 --- a/src/mobile/MuesliTests/Services/SessionsClientTests.swift +++ b/src/mobile/MuesliTests/Services/SessionsClientTests.swift @@ -12,15 +12,15 @@ import XCTest final class SessionsClientTests: XCTestCase { func testDecodesBlendResponse() throws { let json = #""" - { - "blendedMarkdown": "Hello.", - "userNoteSpans": [{ "start": 0, "end": 6 }], - "quoteSpans": [{ "start": 0, "end": 5, "transcriptStart": 1.0, "transcriptEnd": 2.0, "speaker": "Sarah" }], - "imagePlacements": [{ "imageId": "p1", "charOffset": 6 }], - "citations": [{ "blendStart": 0, "blendEnd": 6, "transcriptStart": 0.0, "transcriptEnd": 1.5 }], - "chapters": [{ "start": 0, "title": "Opening", "summary": "intro" }], - "costMicros": 12345 - } + { + "blendedMarkdown": "Hello.", + "userNoteSpans": [{ "start": 0, "end": 6 }], + "quoteSpans": [{ "start": 0, "end": 5, "transcriptStart": 1.0, "transcriptEnd": 2.0, "speaker": "Sarah" }], + "imagePlacements": [{ "imageId": "p1", "charOffset": 6 }], + "citations": [{ "blendStart": 0, "blendEnd": 6, "transcriptStart": 0.0, "transcriptEnd": 1.5 }], + "chapters": [{ "start": 0, "title": "Opening", "summary": "intro" }], + "costMicros": 12345 + } """#.data(using: .utf8)! let resp = try JSONDecoder().decode(BlendResponse.self, from: json) @@ -28,7 +28,7 @@ final class SessionsClientTests: XCTestCase { XCTAssertEqual(resp.userNoteSpans.count, 1) XCTAssertEqual(resp.quoteSpans.first?.speaker, "Sarah") XCTAssertEqual(resp.chapters.count, 1) - XCTAssertEqual(resp.costMicros, 12345) + XCTAssertEqual(resp.costMicros, 12_345) } func testEncodesBlendRequest() throws { diff --git a/src/mobile/MuesliTests/SwiftData/SwiftDataTests.swift b/src/mobile/MuesliTests/SwiftData/SwiftDataTests.swift index 233672d..2d4ba96 100644 --- a/src/mobile/MuesliTests/SwiftData/SwiftDataTests.swift +++ b/src/mobile/MuesliTests/SwiftData/SwiftDataTests.swift @@ -12,40 +12,39 @@ import Foundation @Suite("SwiftData Operation Tests", .tags(.swiftdata)) struct SwiftDataTests { - private func createTestContainer() throws -> ModelContainer { return try TestSetup.createTestContainer() } - + @Test("Create and save note") @MainActor func createAndSaveNote() async throws { let container = try createTestContainer() let context = container.mainContext - + let note = Note( title: "Test Note", content: "This is test content", conferenceName: "Test Conference", sessionType: "test" ) - + context.insert(note) try context.save() - + // Verify note was saved let descriptor = FetchDescriptor() let savedNotes = try context.fetch(descriptor) - + #expect(savedNotes.count == 1) #expect(savedNotes.first?.title == "Test Note") #expect(savedNotes.first?.content == "This is test content") } - + @Test("Update existing note") @MainActor func updateExistingNote() async throws { let container = try createTestContainer() let context = container.mainContext - + // Create initial note let note = Note( title: "Original Title", @@ -53,109 +52,109 @@ struct SwiftDataTests { conferenceName: nil, sessionType: "note" ) - + context.insert(note) try context.save() - + // Update the note note.title = "Updated Title" note.content = "Updated content" try context.save() - + // Verify update let descriptor = FetchDescriptor() let savedNotes = try context.fetch(descriptor) - + #expect(savedNotes.count == 1) #expect(savedNotes.first?.title == "Updated Title") #expect(savedNotes.first?.content == "Updated content") } - + @Test("Archive and unarchive note") @MainActor func archiveAndUnarchiveNote() async throws { let container = try createTestContainer() let context = container.mainContext - + let note = Note( title: "Test Note", content: "Test content", conferenceName: nil, sessionType: "note" ) - + context.insert(note) try context.save() - + #expect(note.isArchived == false) - + // Archive the note note.isArchived = true try context.save() - + let archivedDescriptor = FetchDescriptor( predicate: #Predicate { $0.isArchived } ) let archivedNotes = try context.fetch(archivedDescriptor) #expect(archivedNotes.count == 1) - + // Unarchive the note note.isArchived = false try context.save() - + let activeDescriptor = FetchDescriptor( predicate: #Predicate { !$0.isArchived } ) let activeNotes = try context.fetch(activeDescriptor) #expect(activeNotes.count == 1) } - + @Test("Delete note") @MainActor func deleteNote() async throws { let container = try createTestContainer() let context = container.mainContext - + let note = Note( title: "Note to Delete", content: "This will be deleted", conferenceName: nil, sessionType: "note" ) - + context.insert(note) try context.save() - + // Verify note exists let beforeDescriptor = FetchDescriptor() let beforeNotes = try context.fetch(beforeDescriptor) #expect(beforeNotes.count == 1) - + // Delete note context.delete(note) try context.save() - + // Verify note is deleted let afterDescriptor = FetchDescriptor() let afterNotes = try context.fetch(afterDescriptor) - #expect(afterNotes.count == 0) + #expect(afterNotes.isEmpty) } - + @Test("Search notes by title") @MainActor func searchNotesByTitle() async throws { let container = try createTestContainer() let context = container.mainContext - + // Create test notes let notes = [ Note(title: "Meeting Notes", content: "Important meeting", conferenceName: nil, sessionType: "note"), Note(title: "Project Planning", content: "Plan the project", conferenceName: nil, sessionType: "note"), Note(title: "Random Ideas", content: "Some random thoughts", conferenceName: nil, sessionType: "note") ] - + for note in notes { context.insert(note) } try context.save() - + // Search for notes containing "meeting" let searchDescriptor = FetchDescriptor( predicate: #Predicate { note in @@ -163,28 +162,28 @@ struct SwiftDataTests { } ) let searchResults = try context.fetch(searchDescriptor) - + #expect(searchResults.count == 1) #expect(searchResults.first?.title == "Meeting Notes") } - + @Test("Search notes by content") @MainActor func searchNotesByContent() async throws { let container = try createTestContainer() let context = container.mainContext - + // Create test notes let notes = [ Note(title: "Note 1", content: "This contains special keyword", conferenceName: nil, sessionType: "note"), Note(title: "Note 2", content: "This is regular content", conferenceName: nil, sessionType: "note"), Note(title: "Note 3", content: "Another special keyword here", conferenceName: nil, sessionType: "note") ] - + for note in notes { context.insert(note) } try context.save() - + // Search for notes containing "special" let searchDescriptor = FetchDescriptor( predicate: #Predicate { note in @@ -192,68 +191,68 @@ struct SwiftDataTests { } ) let searchResults = try context.fetch(searchDescriptor) - + #expect(searchResults.count == 2) } - + @Test("Filter active notes") @MainActor func filterActiveNotes() async throws { let container = try createTestContainer() let context = container.mainContext - + // Create mix of active and archived notes let notes = [ Note(title: "Active 1", content: "Active note", conferenceName: nil, sessionType: "note"), Note(title: "Active 2", content: "Another active note", conferenceName: nil, sessionType: "note"), Note(title: "Archived 1", content: "Archived note", conferenceName: nil, sessionType: "note") ] - + notes[2].isArchived = true // Archive the third note - + for note in notes { context.insert(note) } try context.save() - + // Fetch only active notes let activeDescriptor = FetchDescriptor( predicate: #Predicate { !$0.isArchived } ) let activeNotes = try context.fetch(activeDescriptor) - + #expect(activeNotes.count == 2) #expect(activeNotes.allSatisfy { !$0.isArchived }) } - + @Test("Sort notes by timestamp") @MainActor func sortNotesByTimestamp() async throws { let container = try createTestContainer() let context = container.mainContext - + let now = Date() let notes = [ Note(title: "Newest", content: "Content", timestamp: now, conferenceName: nil, sessionType: "note", isArchived: false), - Note(title: "Oldest", content: "Content", timestamp: now.addingTimeInterval(-3600), conferenceName: nil, sessionType: "note", isArchived: false), - Note(title: "Middle", content: "Content", timestamp: now.addingTimeInterval(-1800), conferenceName: nil, sessionType: "note", isArchived: false) + Note(title: "Oldest", content: "Content", timestamp: now.addingTimeInterval(-3_600), conferenceName: nil, sessionType: "note", isArchived: false), + Note(title: "Middle", content: "Content", timestamp: now.addingTimeInterval(-1_800), conferenceName: nil, sessionType: "note", isArchived: false) ] - + for note in notes { context.insert(note) } try context.save() - + // Fetch notes sorted by timestamp (newest first) let sortedDescriptor = FetchDescriptor( sortBy: [SortDescriptor(\.timestamp, order: .reverse)] ) let sortedNotes = try context.fetch(sortedDescriptor) - + #expect(sortedNotes.count == 3) #expect(sortedNotes[0].title == "Newest") #expect(sortedNotes[1].title == "Middle") #expect(sortedNotes[2].title == "Oldest") } - + @Test("Note time and date string formatting") @MainActor func noteTimeAndDateStringFormatting() async throws { let note = Note( @@ -262,16 +261,16 @@ struct SwiftDataTests { conferenceName: nil, sessionType: "note" ) - + let timeString = note.timeString let dateString = note.dateString - + #expect(!timeString.isEmpty) #expect(!dateString.isEmpty) - + // Should contain AM or PM for time #expect(timeString.contains("AM") || timeString.contains("PM")) - + // Date should contain current year let currentYear = Calendar.current.component(.year, from: Date()) #expect(dateString.contains(String(currentYear))) diff --git a/src/mobile/MuesliTests/TestHelpers/TestSetup.swift b/src/mobile/MuesliTests/TestHelpers/TestSetup.swift index 6999b06..2aa5a36 100644 --- a/src/mobile/MuesliTests/TestHelpers/TestSetup.swift +++ b/src/mobile/MuesliTests/TestHelpers/TestSetup.swift @@ -12,9 +12,8 @@ import Testing /// Provides test setup utilities and mock data for all tests struct TestSetup { - // MARK: - Test Data Creation - + static func createTestNote( title: String = "Test Note", content: String = "Test content for unit testing", @@ -37,7 +36,7 @@ struct TestSetup { duration: duration ) } - + static func createMultipleTestNotes(count: Int = 3) -> [Note] { return (1...count).map { index in createTestNote( @@ -48,49 +47,49 @@ struct TestSetup { ) } } - + // MARK: - SwiftData Test Container - + static func createTestContainer() throws -> ModelContainer { let schema = Schema([Note.self]) let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) return try ModelContainer(for: schema, configurations: [modelConfiguration]) } - + @MainActor static func setupTestDataInContainer(_ container: ModelContainer) throws { let context = container.mainContext let testNotes = createMultipleTestNotes(count: 5) - + for note in testNotes { context.insert(note) } - + try context.save() } - + // MARK: - Test Isolation Helpers - + /// Creates isolated test instances instead of using shared singletons static func createIsolatedTestInstances() { // Tests should create their own instances or use dependency injection // No more shared singleton initialization that pollutes other tests } - + // MARK: - Test Audio File - + static func createTestAudioFile() throws -> URL { let tempDir = FileManager.default.temporaryDirectory let audioURL = tempDir.appendingPathComponent("test-audio.m4a") - + // Create a minimal empty file for testing try Data().write(to: audioURL) - + return audioURL } - + // MARK: - Mock Network Responses - + static func mockTranscriptionResponse() -> [String: Any] { return [ "transcript": "This is a test transcription response from the mock API.", @@ -102,9 +101,9 @@ struct TestSetup { ] ] } - + // MARK: - Test Constants - + struct TestConstants { static let defaultTimeout: TimeInterval = 5.0 static let testContent = "This is test content for unit testing purposes. It contains enough text to test various parsing and processing functions." @@ -112,14 +111,14 @@ struct TestSetup { static let testConferenceName = "Test Conference 2024" static let testSessionTypes = ["note", "meeting", "brainstorm", "voice-note"] } - + // MARK: - Cleanup - + static func cleanup() throws { // Clean up any temporary test files let tempDir = FileManager.default.temporaryDirectory let testFiles = try FileManager.default.contentsOfDirectory(at: tempDir, includingPropertiesForKeys: nil) - + for file in testFiles where file.pathExtension == "m4a" && file.lastPathComponent.contains("test") { try? FileManager.default.removeItem(at: file) } @@ -146,4 +145,4 @@ extension Note { } // MARK: - Testing Tags -// Tags are defined in NoteModelTests.swift to avoid redeclaration \ No newline at end of file +// Tags are defined in NoteModelTests.swift to avoid redeclaration diff --git a/src/mobile/MuesliTests/Utilities/AudioRecordingManagerTests.swift b/src/mobile/MuesliTests/Utilities/AudioRecordingManagerTests.swift index d2a9d6e..efa5dc6 100644 --- a/src/mobile/MuesliTests/Utilities/AudioRecordingManagerTests.swift +++ b/src/mobile/MuesliTests/Utilities/AudioRecordingManagerTests.swift @@ -12,39 +12,38 @@ import AVFoundation @Suite("Audio Recording Manager Tests", .tags(.recording)) struct AudioRecordingManagerTests { - // Remove shared state dependency init() async throws { // No shared state initialization } - + @Test("Audio recording manager singleton works") func audioRecordingManagerSingletonWorks() async throws { let manager1 = AudioRecordingManager.shared let manager2 = AudioRecordingManager.shared - + #expect(manager1 === manager2) } - + @Test("Recording state initializes correctly") func recordingStateInitializesCorrectly() async throws { let manager = AudioRecordingManager.shared - + #expect(manager.state == .idle) #expect(manager.currentRecordingPath == nil) #expect(manager.recordingDuration == 0) } - + @Test("Permission check returns boolean") func permissionCheckReturnsBoolean() async throws { let manager = AudioRecordingManager.shared - + manager.checkPermission() - + // hasPermission should be a boolean regardless of actual permission #expect(manager.hasPermission == true || manager.hasPermission == false) } - + @Test("Recording error descriptions are provided") func recordingErrorDescriptionsAreProvided() async throws { let errors: [RecordingError] = [ @@ -53,64 +52,64 @@ struct AudioRecordingManagerTests { .fileNotFound, .audioSessionError ] - + for error in errors { #expect(error.errorDescription != nil) #expect(!error.errorDescription!.isEmpty) } - + // Test specific descriptions #expect(RecordingError.permissionDenied.errorDescription?.contains("permission") == true) #expect(RecordingError.recordingFailed.errorDescription?.contains("Recording failed") == true) #expect(RecordingError.fileNotFound.errorDescription?.contains("file not found") == true) #expect(RecordingError.audioSessionError.errorDescription?.contains("session") == true) } - + @Test("Recording URL generation works correctly") func recordingURLGenerationWorksCorrectly() async throws { let manager = AudioRecordingManager.shared let testFileName = "test_recording.m4a" - + // Test with non-existent file let nonExistentURL = manager.getRecordingURL(fileName: "non_existent_file.m4a") #expect(nonExistentURL == nil) - + // Test path generation logic let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let expectedURL = documentsPath.appendingPathComponent(testFileName) - + #expect(expectedURL.lastPathComponent == testFileName) #expect(expectedURL.pathExtension == "m4a") } - + @Test("Delete recording handles missing files gracefully") func deleteRecordingHandlesMissingFilesGracefully() async throws { let manager = AudioRecordingManager.shared let nonExistentFile = "non_existent_recording.m4a" - + // Should not crash when trying to delete non-existent file manager.deleteRecording(fileName: nonExistentFile) - + // Should complete without throwing #expect(Bool(true)) } - + @Test("Recording states are correctly defined") func recordingStatesAreCorrectlyDefined() async throws { let states: [RecordingState] = [.idle, .recording, .paused, .finished] - + #expect(states.count == 4) - + // Test that each state can be compared #expect(RecordingState.idle != RecordingState.recording) #expect(RecordingState.recording != RecordingState.paused) #expect(RecordingState.paused != RecordingState.finished) } - + @Test("Recording manager prevents unauthorized access gracefully") func recordingManagerPreventsUnauthorizedAccessGracefully() async throws { let manager = AudioRecordingManager.shared - + // If permission is denied, operations should handle gracefully if !manager.hasPermission { do { @@ -123,50 +122,49 @@ struct AudioRecordingManagerTests { } } } - + @Test("Recording state transitions are logical") func recordingStateTransitionsAreLogical() async throws { let manager = AudioRecordingManager.shared - + // Initial state should be idle #expect(manager.state == .idle) - + // Test that certain operations are safe when in idle state manager.pauseRecording() // Should not crash manager.resumeRecording() // Should not crash manager.cancelRecording() // Should not crash - + #expect(manager.state == .idle) // Should remain idle } - + @Test("Audio format settings are correctly configured") func audioFormatSettingsAreCorrectlyConfigured() async throws { // Test the expected audio settings that would be used let expectedSettings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatMPEG4AAC), - AVSampleRateKey: 44100, + AVSampleRateKey: 44_100, AVNumberOfChannelsKey: 1, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue ] - + #expect(expectedSettings[AVFormatIDKey] as? Int == Int(kAudioFormatMPEG4AAC)) - #expect(expectedSettings[AVSampleRateKey] as? Int == 44100) + #expect(expectedSettings[AVSampleRateKey] as? Int == 44_100) #expect(expectedSettings[AVNumberOfChannelsKey] as? Int == 1) #expect(expectedSettings[AVEncoderAudioQualityKey] as? Int == AVAudioQuality.high.rawValue) } - + @Test("File naming conventions are consistent") func fileNamingConventionsAreConsistent() async throws { // Test default file naming pattern let uuidPattern = #"^recording_[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}\.m4a$"# - + // Generate a sample UUID-based filename let testUUID = UUID() let filename = "recording_\(testUUID.uuidString).m4a" - + #expect(filename.hasSuffix(".m4a")) #expect(filename.hasPrefix("recording_")) #expect(filename.contains(testUUID.uuidString)) } } - diff --git a/src/mobile/MuesliTests/Utilities/NetworkMonitorTests.swift b/src/mobile/MuesliTests/Utilities/NetworkMonitorTests.swift index 784e984..73babfc 100644 --- a/src/mobile/MuesliTests/Utilities/NetworkMonitorTests.swift +++ b/src/mobile/MuesliTests/Utilities/NetworkMonitorTests.swift @@ -12,49 +12,48 @@ import Network @Suite("Network Monitor Tests", .tags(.network)) struct NetworkMonitorTests { - @Test("Network monitor singleton works") func networkMonitorSingletonWorks() async throws { let monitor1 = NetworkMonitor.shared let monitor2 = NetworkMonitor.shared - + #expect(monitor1 === monitor2) } - + @Test("Network monitor initializes correctly") func networkMonitorInitializesCorrectly() async throws { let monitor = NetworkMonitor.shared - + // Status should be one of the defined values #expect([NetworkStatus.unknown, NetworkStatus.connected, NetworkStatus.disconnected].contains(monitor.status)) - + // isConnected should be a boolean #expect(monitor.isConnected == true || monitor.isConnected == false) } - + @Test("Network status enum has correct cases") func networkStatusEnumHasCorrectCases() async throws { let statuses: [NetworkStatus] = [.unknown, .connected, .disconnected] - + #expect(statuses.count == 3) - + // Test that each status can be compared #expect(NetworkStatus.unknown != NetworkStatus.connected) #expect(NetworkStatus.connected != NetworkStatus.disconnected) #expect(NetworkStatus.disconnected != NetworkStatus.unknown) } - + @Test("Interface type descriptions are provided") func interfaceTypeDescriptionsAreProvided() async throws { let types: [NWInterface.InterfaceType] = [ .wifi, .cellular, .wiredEthernet, .loopback, .other ] - + for type in types { let description = type.description #expect(!description.isEmpty) } - + // Test specific descriptions #expect(NWInterface.InterfaceType.wifi.description == "WiFi") #expect(NWInterface.InterfaceType.cellular.description == "Cellular") @@ -62,43 +61,43 @@ struct NetworkMonitorTests { #expect(NWInterface.InterfaceType.loopback.description == "Loopback") #expect(NWInterface.InterfaceType.other.description == "Other") } - + @Test("Monitor can be started and stopped safely") func monitorCanBeStartedAndStoppedSafely() async throws { let monitor = NetworkMonitor.shared - + // Starting monitoring should not crash monitor.startMonitoring() - + // Stopping monitoring should not crash monitor.stopMonitoring() - + // Multiple start/stop cycles should be safe monitor.startMonitoring() monitor.stopMonitoring() monitor.startMonitoring() monitor.stopMonitoring() - + #expect(Bool(true)) // Should complete without crashes } - + @Test("Connectivity check returns boolean result") func connectivityCheckReturnsBooleanResult() async throws { let monitor = NetworkMonitor.shared - + let isReachable = await monitor.checkConnectivity() #expect(isReachable == true || isReachable == false) } - + @Test("Connectivity check URL is valid") func connectivityCheckURLIsValid() async throws { let testURL = URL(string: "https://api.deepgram.com/v1/listen")! - + #expect(testURL.scheme == "https") #expect(testURL.host == "api.deepgram.com") #expect(testURL.path.contains("/v1/listen")) } - + @Test("Network path status mapping is correct") func networkPathStatusMappingIsCorrect() async throws { // Test the logic that would be used in updateNetworkStatus @@ -107,7 +106,7 @@ struct NetworkMonitorTests { (.unsatisfied, .disconnected), (.requiresConnection, .disconnected) ] - + for (pathStatus, expectedNetworkStatus) in testCases { let mappedStatus: NetworkStatus switch pathStatus { @@ -118,15 +117,15 @@ struct NetworkMonitorTests { @unknown default: mappedStatus = .unknown } - + #expect(mappedStatus == expectedNetworkStatus) } } - + @Test("Connection type detection works") func connectionTypeDetectionWorks() async throws { let monitor = NetworkMonitor.shared - + // connectionType should be nil or a valid interface type if let connectionType = monitor.connectionType { let validTypes: [NWInterface.InterfaceType] = [ @@ -135,38 +134,38 @@ struct NetworkMonitorTests { #expect(validTypes.contains(connectionType)) } } - + @Test("Monitoring queue is properly configured") func monitoringQueueIsProperlyConfigured() async throws { // Test that we can create a dispatch queue with the expected label let testQueue = DispatchQueue(label: "NetworkMonitor") - + #expect(String(describing: testQueue).contains("NetworkMonitor")) } - + @Test("HTTP response status code validation works") func httpResponseStatusCodeValidationWorks() async throws { // Test the logic used in connectivity check let validStatusCodes = [200, 201, 204, 301, 302, 404, 429] let invalidStatusCodes = [500, 502, 503, 504] - + for statusCode in validStatusCodes { #expect(statusCode < 500) // Should be considered valid } - + for statusCode in invalidStatusCodes { #expect(statusCode >= 500) // Should be considered invalid } } - + @Test("Network status changes can be tracked") func networkStatusChangesCanBeTracked() async throws { let monitor = NetworkMonitor.shared - + // Get initial status let initialStatus = monitor.status let initialConnection = monitor.isConnected - + // Status should be consistent if initialStatus == .connected { #expect(initialConnection == true) @@ -175,7 +174,7 @@ struct NetworkMonitorTests { } // .unknown status can have either true or false for isConnected } - + @Test("Interface type enumeration is comprehensive") func interfaceTypeEnumerationIsComprehensive() async throws { // Test that all known interface types have descriptions @@ -186,27 +185,26 @@ struct NetworkMonitorTests { (.loopback, "Loopback"), (.other, "Other") ] - + for (type, expectedDescription) in knownTypes { #expect(type.description == expectedDescription) } } - + @Test("Monitor handles initialization correctly") func monitorHandlesInitializationCorrectly() async throws { // Test that the monitor starts in a valid state let monitor = NetworkMonitor.shared - + // Should have a status (any of the three valid values) #expect([.unknown, .connected, .disconnected].contains(monitor.status)) - + // isConnected should be boolean #expect(monitor.isConnected is Bool) - + // connectionType can be nil or a valid type if let type = monitor.connectionType { #expect(type is NWInterface.InterfaceType) } } } - diff --git a/src/mobile/MuesliTests/Utilities/PerformanceMonitorTests.swift b/src/mobile/MuesliTests/Utilities/PerformanceMonitorTests.swift index f2b5533..bdd28c6 100644 --- a/src/mobile/MuesliTests/Utilities/PerformanceMonitorTests.swift +++ b/src/mobile/MuesliTests/Utilities/PerformanceMonitorTests.swift @@ -11,68 +11,67 @@ import Foundation @Suite("Performance Monitor Tests", .tags(.performance)) struct PerformanceMonitorTests { - // Remove shared singleton dependency - each test should be isolated init() async throws { // No shared state initialization } - + @Test("Performance monitor starts and ends timing correctly") func performanceMonitorStartsAndEndsTimingCorrectly() async throws { // Test the concept without relying on shared singleton state let startTime = Date() - + // Simulate some work try await Task.sleep(nanoseconds: 10_000_000) // 10ms - + let endTime = Date() let duration = endTime.timeIntervalSince(startTime) - + // Verify timing measurement works #expect(duration > 0.005) // Should be at least 5ms #expect(duration < 0.1) // Should be less than 100ms - + // Test report generation concept - let report = "📊 Performance Report\n\nTest Operation: \(String(format: "%.2f", duration * 1000))ms" + let report = "📊 Performance Report\n\nTest Operation: \(String(format: "%.2f", duration * 1_000))ms" #expect(report.contains("Performance Report")) #expect(report.contains("Test Operation")) } - + @Test("Performance monitor measures operation correctly") func performanceMonitorMeasuresOperationCorrectly() async throws { // Test the measurement concept without shared state let startTime = Date() - + // Test operation that returns a result let result = "test_result" - + let endTime = Date() let duration = endTime.timeIntervalSince(startTime) - + // Verify result is correct #expect(result == "test_result") - + // Verify timing measurement #expect(duration >= 0) #expect(duration < 0.1) // Should be very fast - + // Test that we can create operation metrics let operationMetric = (operation: "test_measure", duration: duration, timestamp: Date()) #expect(operationMetric.operation == "test_measure") #expect(operationMetric.duration >= 0) } - + @Test("Performance monitor handles throwing operations") func performanceMonitorHandlesThrowingOperations() async throws { enum TestError: Error { case intentionalError } - + // Test exception handling without shared state let startTime = Date() var operationCompleted = false var errorWasThrown = false - + do { // Simulate an operation that throws throw TestError.intentionalError @@ -83,79 +82,79 @@ struct PerformanceMonitorTests { } catch { #expect(Bool(false)) // Should not catch other errors } - + let endTime = Date() let duration = endTime.timeIntervalSince(startTime) - + // Verify error handling worked correctly #expect(errorWasThrown == true) #expect(operationCompleted == true) #expect(duration >= 0) - + // Test that we can still record metrics for failed operations let failedOperationMetric = (operation: "throwing_operation", duration: duration, success: false) #expect(failedOperationMetric.operation == "throwing_operation") #expect(failedOperationMetric.success == false) } - + @Test("Performance monitor tracks memory usage") func performanceMonitorTracksMemoryUsage() async throws { // Test memory usage tracking concept without shared state let mockMemoryUsage = 64.5 // MB - + // Simulate a performance report with memory data let report = """ 📊 Performance Report - + Memory Usage: • Current: \(String(format: "%.1f", mockMemoryUsage))MB • Average: \(String(format: "%.1f", mockMemoryUsage * 0.8))MB """ - + // Verify memory metrics are included in the report #expect(report.contains("Memory Usage")) #expect(report.contains("64.5MB")) #expect(report.contains("Current:")) #expect(report.contains("Average:")) } - + @Test("Performance monitor formats memory correctly") func performanceMonitorFormatsMemoryCorrectly() async throws { // Test memory formatting helper (this tests the logic used in PerformanceMonitor) let testCases: [(UInt64, String)] = [ (512, "512 B"), - (1024, "1.00 KB"), - (1536, "1.50 KB"), - (1048576, "1.00 MB"), - (1073741824, "1.00 GB") + (1_024, "1.00 KB"), + (1_536, "1.50 KB"), + (1_048_576, "1.00 MB"), + (1_073_741_824, "1.00 GB") ] - + for (bytes, expected) in testCases { let formatted = formatMemorySize(bytes) #expect(formatted == expected) } } - + @Test("Performance monitor handles multiple operations") func performanceMonitorHandlesMultipleOperations() async throws { // Test multiple operations concept without shared state var operationResults: [(String, TimeInterval, Int)] = [] - + // Perform multiple operations sequentially for testing for i in 0..<5 { let operationName = "multiple_operation_\(i)" let startTime = Date() - + // Simulate some work try await Task.sleep(nanoseconds: 1_000_000) // 1ms let result = i - + let endTime = Date() let duration = endTime.timeIntervalSince(startTime) - + operationResults.append((operationName, duration, result)) } - + // Verify all operations were recorded #expect(operationResults.count == 5) for i in 0..<5 { @@ -165,78 +164,78 @@ struct PerformanceMonitorTests { #expect(operation.2 == i) } } - + @Test("Performance monitor provides current metrics") func performanceMonitorProvidesCurrentMetrics() async throws { let monitor = PerformanceMonitor.shared - + // Perform a test operation _ = monitor.measure(operation: "metrics_test") { return "result" } - + // Get performance report let report = monitor.generatePerformanceReport() - + // Verify report is a non-empty string #expect(report is String) #expect(!report.isEmpty) #expect(report.contains("Performance Report")) } - + @Test("Performance monitor resets correctly") func performanceMonitorResetsCorrectly() async throws { // Test reset concept without shared state var metrics: [(String, TimeInterval)] = [] - + // Perform initial operation let startTime1 = Date() let result1 = "result" let endTime1 = Date() let duration1 = endTime1.timeIntervalSince(startTime1) metrics.append(("operation_before_reset", duration1)) - + // Verify initial state #expect(metrics.count == 1) #expect(result1 == "result") - + // Simulate reset by clearing metrics metrics.removeAll() - #expect(metrics.count == 0) - + #expect(metrics.isEmpty) + // Perform new operation after reset let startTime2 = Date() let result2 = "new_result" let endTime2 = Date() let duration2 = endTime2.timeIntervalSince(startTime2) metrics.append(("operation_after_reset", duration2)) - + // Verify the new operation is tracked and old ones are gone #expect(metrics.count == 1) #expect(metrics[0].0 == "operation_after_reset") #expect(result2 == "new_result") } - + @Test("Performance monitor handles edge cases") func performanceMonitorHandlesEdgeCases() async throws { let monitor = PerformanceMonitor.shared - + // Test with empty operation name _ = monitor.measure(operation: "") { return "empty_name_result" } - + // Test with very long operation name - let longName = String(repeating: "A", count: 1000) + let longName = String(repeating: "A", count: 1_000) _ = monitor.measure(operation: longName) { return "long_name_result" } - + // Test with special characters _ = monitor.measure(operation: "special!@#$%^&*()_+{}|:<>?[]\\;',./") { return "special_chars_result" } - + // Verify all operations were handled let report = monitor.generatePerformanceReport() #expect(report.contains("Performance Report")) @@ -246,15 +245,14 @@ struct PerformanceMonitorTests { // MARK: - Supporting Functions for Testing extension PerformanceMonitorTests { - /// Helper function to format memory size (mirrors PerformanceMonitor logic) func formatMemorySize(_ bytes: UInt64) -> String { - let kb = 1024.0 - let mb = kb * 1024.0 - let gb = mb * 1024.0 - + let kb = 1_024.0 + let mb = kb * 1_024.0 + let gb = mb * 1_024.0 + let bytesDouble = Double(bytes) - + if bytesDouble >= gb { return String(format: "%.2f GB", bytesDouble / gb) } else if bytesDouble >= mb { @@ -265,42 +263,42 @@ extension PerformanceMonitorTests { return "\(bytes) B" } } - + @Test("Memory formatting helper works correctly") func memoryFormattingHelperWorksCorrectly() async throws { // Test various memory sizes #expect(formatMemorySize(0) == "0 B") #expect(formatMemorySize(500) == "500 B") - #expect(formatMemorySize(1024) == "1.00 KB") - #expect(formatMemorySize(2048) == "2.00 KB") - #expect(formatMemorySize(1048576) == "1.00 MB") - #expect(formatMemorySize(2097152) == "2.00 MB") - #expect(formatMemorySize(1073741824) == "1.00 GB") + #expect(formatMemorySize(1_024) == "1.00 KB") + #expect(formatMemorySize(2_048) == "2.00 KB") + #expect(formatMemorySize(1_048_576) == "1.00 MB") + #expect(formatMemorySize(2_097_152) == "2.00 MB") + #expect(formatMemorySize(1_073_741_824) == "1.00 GB") } - + /// Helper to simulate performance-critical operations func performCPUIntensiveTask() -> Int { var result = 0 - for i in 0..<1000 { + for i in 0..<1_000 { result += i * i } return result } - + @Test("Performance monitoring during CPU intensive tasks") func performanceMonitoringDuringCPUIntensiveTasks() async throws { // Test CPU intensive monitoring without shared state let startTime = Date() - + let result = performCPUIntensiveTask() - + let endTime = Date() let duration = endTime.timeIntervalSince(startTime) - + #expect(result > 0) // Should calculate a positive result #expect(duration > 0) // Should take some time #expect(duration < 1.0) // But not too long for tests - + // Test that we can record CPU intensive operations let cpuMetric = (operation: "cpu_intensive_task", duration: duration, result: result) #expect(cpuMetric.operation == "cpu_intensive_task") @@ -309,4 +307,4 @@ extension PerformanceMonitorTests { } // MARK: - Test Tags Extension -// Note: Tags are defined in NoteModelTests.swift to avoid redefinition \ No newline at end of file +// Note: Tags are defined in NoteModelTests.swift to avoid redefinition diff --git a/src/mobile/MuesliTests/Utilities/SimpleSummaryGeneratorTests.swift b/src/mobile/MuesliTests/Utilities/SimpleSummaryGeneratorTests.swift index 6b4b597..5689771 100644 --- a/src/mobile/MuesliTests/Utilities/SimpleSummaryGeneratorTests.swift +++ b/src/mobile/MuesliTests/Utilities/SimpleSummaryGeneratorTests.swift @@ -10,7 +10,6 @@ import XCTest @MainActor final class SimpleSummaryGeneratorTests: XCTestCase { - // MARK: - Title Generation Tests func testGenerateTitleFromTranscript() { diff --git a/src/mobile/MuesliTests/Utilities/TranscriptionFallbackTests.swift b/src/mobile/MuesliTests/Utilities/TranscriptionFallbackTests.swift index b6d772d..4382695 100644 --- a/src/mobile/MuesliTests/Utilities/TranscriptionFallbackTests.swift +++ b/src/mobile/MuesliTests/Utilities/TranscriptionFallbackTests.swift @@ -2,254 +2,188 @@ // TranscriptionFallbackTests.swift // MuesliTests // -// Created by Claude on 8/27/25. -// Tests for graceful fallback behavior when transcription services are unavailable +// Tests for graceful fallback behavior when transcription services are unavailable. +// Uses TestWorld to inject a FakeTranscriptionAdapter so no real network traffic occurs. // import Testing import Foundation @testable import Muesli +@MainActor struct TranscriptionFallbackTests { - + private let transcription: FakeTranscriptionAdapter + private let network: FakeNetworkAdapter + + init() { + let installed = TestWorld.install() + self.transcription = installed.transcription + self.network = installed.network + } + // MARK: - Real-time Transcription Fallback Tests - + @Test("Real-time transcription gracefully handles API unavailable") func realTimeTranscriptionHandlesAPIUnavailable() async throws { - let service = TranscriptionService.shared - - // Test with the service in its current state (may or may not have API configured) - - // Test with invalid API endpoint - let success = await service.startRealtimeTranscription() - - // Should return false (graceful failure) instead of crashing + transcription.stubHasValidEndpoint = false + transcription.stubStartReturns = false + + let success = await World.current.transcription.startRealtimeTranscription() + #expect(success == false) - #expect(service.isTranscribing == false) - - // Ensure service is in a clean state - service.stopRealtimeTranscription() + #expect(World.current.transcription.isTranscribing == false) + + World.current.transcription.stopRealtimeTranscription() } - + @Test("Real-time transcription handles network unavailable gracefully") func realTimeTranscriptionHandlesNetworkUnavailable() async throws { - let service = TranscriptionService.shared - let networkMonitor = NetworkMonitor.shared - - // Simulate network disconnected - // Note: In a real test environment, we'd mock NetworkMonitor - // For now, we test the logic path - - let success = await service.startRealtimeTranscription() - - // If network is available and API is configured, should succeed - // If network is unavailable, should fail gracefully - // Either way, no crashes should occur - #expect(success == true || success == false) // Should return a boolean, not crash - - // Clean up - if service.isTranscribing { - service.stopRealtimeTranscription() - } + network.stubIsConnected = false + transcription.stubStartReturns = false + + let success = await World.current.transcription.startRealtimeTranscription() + + #expect(success == false) + #expect(World.current.transcription.isTranscribing == false) } - + @Test("Multiple start/stop cycles don't cause issues") func multipleStartStopCyclesDontCauseIssues() async throws { - let service = TranscriptionService.shared - - // Test multiple rapid start/stop cycles for _ in 0..<5 { - let success = await service.startRealtimeTranscription() - service.stopRealtimeTranscription() - - // Should handle rapid cycling gracefully - #expect(service.isTranscribing == false) + _ = await World.current.transcription.startRealtimeTranscription() + World.current.transcription.stopRealtimeTranscription() + + #expect(World.current.transcription.isTranscribing == false) } + + #expect(transcription.startCount == 5) + #expect(transcription.stopCount == 5) } - + // MARK: - Batch Transcription Fallback Tests - + @Test("Batch transcription handles invalid file gracefully") func batchTranscriptionHandlesInvalidFileGracefully() async throws { - let service = TranscriptionService.shared - - // Test with non-existent file let invalidURL = URL(fileURLWithPath: "/tmp/nonexistent.m4a") - let result = await service.transcribeAudioFile(url: invalidURL) - - // Should return nil instead of crashing + let result = await World.current.transcription.transcribeAudioFile(url: invalidURL) + #expect(result == nil) } - + @Test("Batch transcription handles API unavailable gracefully") func batchTranscriptionHandlesAPIUnavailableGracefully() async throws { - let service = TranscriptionService.shared - - // Create a temporary dummy audio file + transcription.stubHasValidEndpoint = false + transcription.stubFileTranscript = nil + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("test.m4a") - let dummyData = Data([0x00, 0x01, 0x02, 0x03]) // Dummy data - try dummyData.write(to: tempURL) - - defer { - try? FileManager.default.removeItem(at: tempURL) - } - - let result = await service.transcribeAudioFile(url: tempURL) - - // Should return nil gracefully when API is unavailable - // (assuming test environment doesn't have API configured) - #expect(result == nil || result != nil) // Should not crash, return value depends on API availability + try Data([0x00, 0x01, 0x02, 0x03]).write(to: tempURL) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let result = await World.current.transcription.transcribeAudioFile(url: tempURL) + + #expect(result == nil) } - + // MARK: - NetworkMonitor Integration Tests - + @Test("Network monitor state changes are handled gracefully") func networkMonitorStateChangesAreHandledGracefully() async throws { - let monitor = NetworkMonitor.shared - - // Test that network monitor doesn't crash on state queries - let isConnected = monitor.isConnected - #expect(isConnected == true || isConnected == false) // Should return a boolean - - // Test starting monitoring multiple times - monitor.startMonitoring() - monitor.startMonitoring() // Should handle duplicate starts - - // Test stopping monitoring - monitor.stopMonitoring() - monitor.stopMonitoring() // Should handle duplicate stops + // Just confirm the port returns a stable boolean and start/stop are idempotent. + _ = World.current.network.isConnected + World.current.network.startMonitoring() + World.current.network.startMonitoring() + World.current.network.stopMonitoring() + World.current.network.stopMonitoring() + + #expect(network.startMonitoringCount == 2) + #expect(network.stopMonitoringCount == 2) } - + // MARK: - Integration Tests for NewNoteView Logic - + @Test("Transcription service integration doesn't crash on failures") func transcriptionServiceIntegrationDoesntCrashOnFailures() async throws { - let service = TranscriptionService.shared - let networkMonitor = NetworkMonitor.shared - // Simulate the logic from NewNoteView.tryStartTranscription() - let canAttemptTranscription = networkMonitor.isConnected && service.hasValidAPIEndpoint - + network.stubIsConnected = true + transcription.stubHasValidEndpoint = true + transcription.stubStartReturns = true + + let canAttemptTranscription = + World.current.network.isConnected && World.current.transcription.hasValidAPIEndpoint + if canAttemptTranscription { - let success = await service.startRealtimeTranscription() - #expect(success == true || success == false) // Should not crash - - if service.isTranscribing { - service.stopRealtimeTranscription() + let success = await World.current.transcription.startRealtimeTranscription() + #expect(success == true) + if World.current.transcription.isTranscribing { + World.current.transcription.stopRealtimeTranscription() } } - - // This test should complete without any crashes regardless of API availability - #expect(true) // If we reach here, no crashes occurred } - + // MARK: - Cleanup and State Management Tests - + @Test("Service cleanup is idempotent") func serviceCleanupIsIdempotent() async throws { - let service = TranscriptionService.shared - - // Start transcription (may or may not succeed) - let _ = await service.startRealtimeTranscription() - - // Stop multiple times - should be safe - service.stopRealtimeTranscription() - service.stopRealtimeTranscription() - service.stopRealtimeTranscription() - - #expect(service.isTranscribing == false) + _ = await World.current.transcription.startRealtimeTranscription() + World.current.transcription.stopRealtimeTranscription() + World.current.transcription.stopRealtimeTranscription() + World.current.transcription.stopRealtimeTranscription() + + #expect(World.current.transcription.isTranscribing == false) + #expect(transcription.stopCount == 3) } - + @Test("Service state remains consistent after failures") func serviceStateRemainsConsistentAfterFailures() async throws { - let service = TranscriptionService.shared - - // Record initial state - let initiallyTranscribing = service.isTranscribing - - // Attempt to start (may fail gracefully) - let _ = await service.startRealtimeTranscription() - - // Stop transcription - service.stopRealtimeTranscription() - - // State should be clean - #expect(service.isTranscribing == false) + _ = await World.current.transcription.startRealtimeTranscription() + World.current.transcription.stopRealtimeTranscription() + + #expect(World.current.transcription.isTranscribing == false) } - + // MARK: - Error Callback Tests - + @Test("Error callbacks don't crash when called") func errorCallbacksDontCrashWhenCalled() async throws { - let service = TranscriptionService.shared - var errorReceived: Error? - - // Set up error callback - service.onError = { error in + World.current.transcription.onError = { error in errorReceived = error } - - // Attempt transcription that may trigger error callback - let _ = await service.startRealtimeTranscription() - - // Clean up - service.onError = nil - service.stopRealtimeTranscription() - - // Test passes if no crashes occur - #expect(true) // We reached the end without crashing + + _ = await World.current.transcription.startRealtimeTranscription() + + World.current.transcription.onError = nil + World.current.transcription.stopRealtimeTranscription() + + // The fake does not invoke onError, so this should remain nil. + #expect(errorReceived == nil) } - + // MARK: - Configuration Tests - + @Test("Service configuration queries are safe") func serviceConfigurationQueriesAreSafe() async throws { - let service = TranscriptionService.shared - - // These properties should be safely queryable - let hasValidEndpoint = service.hasValidAPIEndpoint - let environmentName = service.environmentName - - #expect(hasValidEndpoint == true || hasValidEndpoint == false) - #expect(!environmentName.isEmpty) + let hasValidEndpoint = World.current.transcription.hasValidAPIEndpoint + let environmentName = World.current.transcription.environmentName + + #expect(hasValidEndpoint == true) + #expect(environmentName == "test") } - + // MARK: - Stress Test - + @Test("Rapid transcription requests don't cause crashes") func rapidTranscriptionRequestsDontCauseCrashes() async throws { - let service = TranscriptionService.shared - - // Create multiple concurrent transcription attempts - await withTaskGroup(of: Void.self) { group in - for _ in 0..<10 { - group.addTask { - let _ = await service.startRealtimeTranscription() - service.stopRealtimeTranscription() - } - } + // Sequential rapid cycling against the fake. The previous concurrent + // task group was meaningless against a singleton; sequential cycling + // exercises the same idempotency property without thread-safety noise. + for _ in 0..<10 { + _ = await World.current.transcription.startRealtimeTranscription() + World.current.transcription.stopRealtimeTranscription() } - - // Ensure service is in clean state - service.stopRealtimeTranscription() - #expect(service.isTranscribing == false) - } -} -// MARK: - Mock Helpers for Future Enhancement - -extension TranscriptionFallbackTests { - - /// Helper to simulate network connectivity changes - /// In a more advanced test suite, we could create a MockNetworkMonitor - private func simulateNetworkChange() { - // Future: Implement network state mocking - } - - /// Helper to simulate API endpoint changes - /// In a more advanced test suite, we could create a MockTranscriptionService - private func simulateAPIChange() { - // Future: Implement API endpoint mocking + #expect(World.current.transcription.isTranscribing == false) + #expect(transcription.startCount == 10) + #expect(transcription.stopCount == 10) } -} \ No newline at end of file +} diff --git a/src/mobile/MuesliTests/Utilities/TranscriptionServiceTests.swift b/src/mobile/MuesliTests/Utilities/TranscriptionServiceTests.swift index 0aa4e5c..57ffc9a 100644 --- a/src/mobile/MuesliTests/Utilities/TranscriptionServiceTests.swift +++ b/src/mobile/MuesliTests/Utilities/TranscriptionServiceTests.swift @@ -9,31 +9,27 @@ import Testing import Foundation @testable import Muesli +@MainActor @Suite("Transcription Service Tests", .tags(.transcription)) struct TranscriptionServiceTests { - - // Remove shared state dependency + private let transcription: FakeTranscriptionAdapter + init() async throws { - // No shared state initialization + self.transcription = TestWorld.install().transcription } - - @Test("Transcription service singleton works") - func transcriptionServiceSingletonWorks() async throws { - // Test singleton pattern without affecting other tests - let service1 = TranscriptionService.shared - let service2 = TranscriptionService.shared - - #expect(service1 === service2) + + @Test("World.current.transcription returns a stable reference") + func transcriptionPortIsStable() async throws { + let first = World.current.transcription + let second = World.current.transcription + #expect(first === second) } - - @Test("Transcription service initializes correctly") - func transcriptionServiceInitializesCorrectly() async throws { - let service = TranscriptionService.shared - - #expect(service.isTranscribing == false) - #expect(service.currentTranscript == "") + + @Test("Transcription port initializes with isTranscribing == false") + func transcriptionPortInitialState() async throws { + #expect(World.current.transcription.isTranscribing == false) } - + @Test("Transcription error descriptions are provided") func transcriptionErrorDescriptionsAreProvided() async throws { let errors: [TranscriptionError] = [ @@ -43,12 +39,12 @@ struct TranscriptionServiceTests { .decodingError, .serviceUnavailable ] - + for error in errors { #expect(error.errorDescription != nil) #expect(!error.errorDescription!.isEmpty) } - + // Test specific descriptions #expect(TranscriptionError.apiEndpointNotConfigured.errorDescription?.contains("endpoint") == true) #expect(TranscriptionError.networkError.errorDescription?.contains("Network") == true) @@ -56,26 +52,26 @@ struct TranscriptionServiceTests { #expect(TranscriptionError.decodingError.errorDescription?.contains("decode") == true) #expect(TranscriptionError.serviceUnavailable.errorDescription?.contains("unavailable") == true) } - + @Test("Transcription result struct works correctly") func transcriptionResultStructWorksCorrectly() async throws { let testText = "Hello world" let testConfidence = 0.95 let testTimestamp = Date().timeIntervalSince1970 - + let result = TranscriptionResult( text: testText, confidence: testConfidence, isFinal: true, timestamp: testTimestamp ) - + #expect(result.text == testText) #expect(result.confidence == testConfidence) #expect(result.isFinal == true) #expect(result.timestamp == testTimestamp) } - + @Test("Deepgram response structures are decodable") func deepgramResponseStructuresAreDecodable() async throws { let jsonString = """ @@ -94,14 +90,14 @@ struct TranscriptionServiceTests { } } """ - + guard let data = jsonString.data(using: .utf8) else { throw TranscriptionError.decodingError } - + do { let response = try JSONDecoder().decode(DeepgramResponse.self, from: data) - + #expect(response.results.channels.count == 1) #expect(response.results.channels[0].alternatives.count == 1) #expect(response.results.channels[0].alternatives[0].transcript == "Hello world") @@ -110,31 +106,31 @@ struct TranscriptionServiceTests { #expect(Bool(false)) // Decoding should succeed } } - + @Test("API endpoint configuration works correctly") func apiEndpointConfigurationWorksCorrectly() async throws { // Test API configuration without shared state let primaryURL = APIConfiguration.transcriptionAPIBaseURL let fallbackURL = APIConfiguration.fallbackAPIBaseURL let environmentName = APIConfiguration.environmentName - + // Test that endpoints are properly configured #expect(!primaryURL.isEmpty) #expect(!fallbackURL.isEmpty) #expect(!environmentName.isEmpty) - + // Test environment detection #if DEBUG #expect(environmentName == "Development") #else #expect(environmentName == "Production") #endif - + // Test URL validation #expect(primaryURL.hasPrefix("http")) #expect(fallbackURL.hasPrefix("http")) } - + @Test("Configuration is build-time determined") func configurationIsBuildTimeDetermined() async throws { // Test that API configuration is determined at build time @@ -142,12 +138,12 @@ struct TranscriptionServiceTests { let fallbackURL = APIConfiguration.fallbackAPIBaseURL let environmentName = APIConfiguration.environmentName let isDevelopment = APIConfiguration.isDevelopment - + // All values should be non-empty strings #expect(!primaryURL.isEmpty) #expect(!fallbackURL.isEmpty) #expect(!environmentName.isEmpty) - + // Development flag should be consistent with DEBUG build #if DEBUG #expect(isDevelopment == true) @@ -157,82 +153,41 @@ struct TranscriptionServiceTests { #expect(environmentName == "Production") #endif } - - @Test("Service configuration status is accurate") - func serviceConfigurationStatusIsAccurate() async throws { - let service = TranscriptionService.shared - - // Wait a moment for async initialization to complete - try await Task.sleep(for: .seconds(0.1)) - - // Service should always have valid endpoint with new config system - #expect(service.hasValidAPIEndpoint == true) - - // Current endpoint should not be empty - #expect(!service.currentAPIEndpoint.isEmpty) - - // Environment name should be set - #expect(!service.environmentName.isEmpty) - } - - @Test("Real-time transcription state management works") - func realTimeTranscriptionStateManagementWorks() async throws { - let service = TranscriptionService.shared - - // Initial state - #expect(service.isTranscribing == false) - #expect(service.currentTranscript == "") - - // Stop should be safe even when not started - service.stopRealtimeTranscription() - #expect(service.isTranscribing == false) - } - - @Test("Batch transcription validates input parameters") - func batchTranscriptionValidatesInputParameters() async throws { - let service = TranscriptionService.shared - - // Test with invalid file URL - let invalidURL = URL(string: "file:///nonexistent/path/file.m4a")! - - let result = await service.transcribeAudioFile(url: invalidURL) - #expect(result == nil) // Should return nil for invalid file - } - + @Test("WebSocket URL transformation works correctly") func webSocketURLTransformationWorksCorrectly() async throws { let httpsURL = "https://api.example.com/v1/transcribe/realtime" let expectedWSURL = "wss://api.example.com/v1/transcribe/realtime" - + let transformedURL = httpsURL.replacingOccurrences(of: "https://", with: "wss://") #expect(transformedURL == expectedWSURL) - + // Test URL creation guard let url = URL(string: transformedURL) else { #expect(Bool(false)) // URL should be valid return } - + #expect(url.scheme == "wss") #expect(url.host == "api.example.com") } - + @Test("Multipart form data structure is correct") func multipartFormDataStructureIsCorrect() async throws { let boundary = "test-boundary" let testData = Data("test audio data".utf8) - + var body = Data() - + // Add form data structure body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"audio\"; filename=\"recording.m4a\"\r\n".data(using: .utf8)!) body.append("Content-Type: audio/mp4\r\n\r\n".data(using: .utf8)!) body.append(testData) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) - + let bodyString = String(data: body, encoding: .utf8)! - + #expect(bodyString.contains("--\(boundary)")) #expect(bodyString.contains("Content-Disposition: form-data")) #expect(bodyString.contains("name=\"audio\"")) @@ -240,24 +195,24 @@ struct TranscriptionServiceTests { #expect(bodyString.contains("Content-Type: audio/mp4")) #expect(bodyString.contains("test audio data")) } - + @Test("JSON response parsing handles various formats") func jsonResponseParsingHandlesVariousFormats() async throws { // Test successful response format let successResponse = ["transcript": "Hello world"] let successData = try JSONSerialization.data(withJSONObject: successResponse) - + if let json = try JSONSerialization.jsonObject(with: successData) as? [String: Any], let transcript = json["transcript"] as? String { #expect(transcript == "Hello world") } else { #expect(Bool(false)) // Should successfully parse } - + // Test malformed response let malformedResponse = ["error": "invalid"] let malformedData = try JSONSerialization.data(withJSONObject: malformedResponse) - + if let json = try JSONSerialization.jsonObject(with: malformedData) as? [String: Any], let transcript = json["transcript"] as? String { #expect(Bool(false)) // Should not find transcript @@ -265,12 +220,12 @@ struct TranscriptionServiceTests { #expect(Bool(true)) // Expected - no transcript field } } - + @Test("Localhost detection works in development") func localhostDetectionWorksInDevelopment() async throws { // Test that localhost detection function exists and works let localhostAvailable = await APIConfiguration.checkLocalhostAvailability() - + #if DEBUG // In development, the check should complete (regardless of result) #expect(localhostAvailable == false) // Likely false unless local server running @@ -279,15 +234,15 @@ struct TranscriptionServiceTests { #expect(localhostAvailable == false) #endif } - - @Test("Current API URL selection works correctly") + + @Test("Current API URL selection works correctly") func currentAPIURLSelectionWorksCorrectly() async throws { // Test that getCurrentAPIURL returns a valid URL let currentURL = await APIConfiguration.getCurrentAPIURL() - + #expect(!currentURL.isEmpty) #expect(URL(string: currentURL) != nil) // Should be a valid URL - + // In development, should check localhost then fallback #if DEBUG // Should be either localhost or fallback URL @@ -299,4 +254,4 @@ struct TranscriptionServiceTests { #expect(currentURL == APIConfiguration.transcriptionAPIBaseURL) #endif } -} \ No newline at end of file +} diff --git a/src/mobile/MuesliTests/Utilities/UtilitiesTests.swift b/src/mobile/MuesliTests/Utilities/UtilitiesTests.swift index ebc2daf..5f2add4 100644 --- a/src/mobile/MuesliTests/Utilities/UtilitiesTests.swift +++ b/src/mobile/MuesliTests/Utilities/UtilitiesTests.swift @@ -11,7 +11,6 @@ import Foundation @Suite("Utilities Tests", .tags(.utilities)) struct UtilitiesTests { - @Test("Extract personal notes finds action items") func extractPersonalNotesFindsActionItems() async throws { let content = """ @@ -21,13 +20,13 @@ struct UtilitiesTests { - Another general point - [Action] Review the proposal """ - + let personalNotes = ContentUtilities.extractPersonalNotes(from: content) - + #expect(!personalNotes.isEmpty) #expect(personalNotes.contains { $0.contains("email") } || personalNotes.contains { $0.contains("proposal") }) } - + @Test("Extract personal notes handles no personal content") func extractPersonalNotesHandlesNoPersonalContent() async throws { let content = """ @@ -35,11 +34,11 @@ struct UtilitiesTests { - General discussion point - Another general point """ - + let personalNotes = ContentUtilities.extractPersonalNotes(from: content) #expect(personalNotes.isEmpty) } - + @Test("Extract personal notes handles empty content") func extractPersonalNotesHandlesEmptyContent() async throws { let personalNotes = ContentUtilities.extractPersonalNotes(from: "") diff --git a/src/mobile/MuesliTests/ViewModels/ChatViewModelTests.swift b/src/mobile/MuesliTests/ViewModels/ChatViewModelTests.swift new file mode 100644 index 0000000..b4f7160 --- /dev/null +++ b/src/mobile/MuesliTests/ViewModels/ChatViewModelTests.swift @@ -0,0 +1,110 @@ +// +// ChatViewModelTests.swift +// MuesliTests +// + +import Testing +import Foundation +import SwiftData +@testable import Muesli + +@Suite("Chat View Model Tests", .tags(.unit)) +struct ChatViewModelTests { + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + final class StubChat: ChatPort, @unchecked Sendable { + var stub = ChatResponse( + message: ChatTurn(role: "assistant", content: "ok"), + citations: [] + ) + private(set) var calls: [(ChatScope, [ChatTurn])] = [] + func send(scope: ChatScope, messages: [ChatTurn]) async throws -> ChatResponse { + calls.append((scope, messages)) + return stub + } + } + + @Test("send persists user + assistant messages to the ChatThread") + @MainActor + func sendPersists() async throws { + let container = try makeContainer() + let context = container.mainContext + let stub = StubChat() + let thread = ChatThread(scopeKind: .talk, scopeId: UUID()) + context.insert(thread) + try context.save() + + let vm = ChatViewModel(thread: thread, chat: stub, context: context) + try await vm.send(content: "hi") + + let messages = thread.messages.sorted { $0.createdAt < $1.createdAt } + #expect(messages.count == 2) + #expect(messages[0].role == .user) + #expect(messages[0].content == "hi") + #expect(messages[1].role == .assistant) + #expect(messages[1].content == "ok") + } + + @Test("send rolls back the optimistic user message on failure") + @MainActor + func sendRollsBackOnFailure() async throws { + let container = try makeContainer() + let context = container.mainContext + struct ThrowingChat: ChatPort, @unchecked Sendable { + func send(scope: ChatScope, messages: [ChatTurn]) async throws -> ChatResponse { + throw NSError(domain: "test", code: 1) + } + } + let thread = ChatThread(scopeKind: .talk, scopeId: UUID()) + context.insert(thread) + try context.save() + let vm = ChatViewModel(thread: thread, chat: ThrowingChat(), context: context) + await #expect(throws: Error.self) { + try await vm.send(content: "hi") + } + #expect(thread.messages.isEmpty) + } + + @Test("send encodes citations onto the assistant message") + @MainActor + func sendCarriesCitations() async throws { + let container = try makeContainer() + let context = container.mainContext + let stub = StubChat() + stub.stub = ChatResponse( + message: ChatTurn(role: "assistant", content: "see"), + citations: [ChatCitation(kind: .note, talkId: nil, noteId: UUID(), startSec: nil, endSec: nil, label: nil, title: "T")] + ) + let thread = ChatThread(scopeKind: .talk, scopeId: UUID()) + context.insert(thread) + try context.save() + let vm = ChatViewModel(thread: thread, chat: stub, context: context) + try await vm.send(content: "?") + + let assistant = thread.messages.first { $0.role == .assistant } + let citations = (assistant?.citationsJSON).flatMap { + try? JSONDecoder().decode([ChatCitation].self, from: $0) + } + #expect(citations?.count == 1) + #expect(citations?.first?.kind == .note) + } + + @Test("send is a no-op for whitespace-only input") + @MainActor + func sendNoOpEmpty() async throws { + let container = try makeContainer() + let context = container.mainContext + let stub = StubChat() + let thread = ChatThread(scopeKind: .talk, scopeId: UUID()) + context.insert(thread) + try context.save() + let vm = ChatViewModel(thread: thread, chat: stub, context: context) + try await vm.send(content: " \n\t ") + #expect(thread.messages.isEmpty) + #expect(stub.calls.isEmpty) + } +} diff --git a/src/mobile/MuesliTests/Views/AISummaryEditorViewTests.swift b/src/mobile/MuesliTests/Views/AISummaryEditorViewTests.swift deleted file mode 100644 index 8687d2f..0000000 --- a/src/mobile/MuesliTests/Views/AISummaryEditorViewTests.swift +++ /dev/null @@ -1,189 +0,0 @@ -// -// AISummaryEditorViewTests.swift -// MuesliTests -// -// Tests for AISummaryEditorView functionality -// - -import Testing -import Foundation -import SwiftUI -@testable import Muesli - -@Suite("AI Summary Editor View Tests", .tags(.views)) -struct AISummaryEditorViewTests { - - @Test("AI summary generates appropriate content for note") - func aiSummaryGeneratesAppropriateContent() async throws { - let note = Note( - title: "Test Meeting", - content: "Discussed project timeline. Need to review budget. Action items: email client, update documentation.", - sessionType: "meeting" - ) - - // Test the simulated summary generation logic - let wordCount = note.content.components(separatedBy: .whitespacesAndNewlines).count - #expect(wordCount > 0) - - // Verify content contains actionable items - #expect(note.content.contains("Action items")) - #expect(note.content.contains("email")) - } - - @Test("AI summary handles different session types") - func aiSummaryHandlesDifferentSessionTypes() async throws { - let sessionTypes = ["meeting", "note", "session"] - - for sessionType in sessionTypes { - let note = Note( - title: "Test \(sessionType.capitalized)", - content: "Sample content for testing", - sessionType: sessionType - ) - - #expect(["meeting", "note", "session"].contains(note.sessionType)) - } - } - - @Test("AI summary word count calculation") - func aiSummaryWordCountCalculation() async throws { - let testCases = [ - ("", 0), - ("single", 1), - ("two words", 2), - ("multiple words in sentence", 4), - (" spaced words ", 2), // Extra whitespace should be handled - ("line\nbreak\nwords", 3) - ] - - for (content, expectedCount) in testCases { - let wordCount = content.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - #expect(wordCount == expectedCount) - } - } - - @Test("AI summary content analysis detects key elements") - func aiSummaryContentAnalysisDetectsKeyElements() async throws { - let meetingContent = """ - Meeting started at 9 AM with all team members present. - Discussed Q4 goals and budget allocation. - Action items: review contracts, schedule follow-up meeting. - Next steps: prepare presentation for client. - """ - - // Test detection of meeting-specific elements - #expect(meetingContent.contains("Meeting")) - #expect(meetingContent.contains("Action items")) - #expect(meetingContent.contains("Next steps")) - - // Test word count for content analysis - let wordCount = meetingContent.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - #expect(wordCount > 20) // Should be substantial content - } - - @Test("AI summary generates different insights based on content length") - func aiSummaryGeneratesDifferentInsightsBasedOnContentLength() async throws { - let shortContent = "Brief note" - let mediumContent = String(repeating: "word ", count: 25) // ~25 words - let longContent = String(repeating: "detailed content word ", count: 100) // ~300 words - - let shortWordCount = shortContent.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - let mediumWordCount = mediumContent.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - let longWordCount = longContent.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - - #expect(shortWordCount < 10) - #expect(mediumWordCount >= 20 && mediumWordCount < 50) - #expect(longWordCount >= 100) - } - - @Test("AI summary handles empty or minimal content") - func aiSummaryHandlesEmptyOrMinimalContent() async throws { - let emptyNote = Note( - title: "Empty Note", - content: "", - sessionType: "note" - ) - - let minimalNote = Note( - title: "Minimal Note", - content: "Just a word", - sessionType: "note" - ) - - // Test empty content - let emptyWordCount = emptyNote.content.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - #expect(emptyWordCount == 0) - - // Test minimal content - let minimalWordCount = minimalNote.content.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - #expect(minimalWordCount > 0) - } - - @Test("AI summary content structure analysis") - func aiSummaryContentStructureAnalysis() async throws { - let structuredContent = """ - # Meeting Notes - ## Agenda Items - • Project status update - • Budget review - • Resource allocation - - ## Action Items - • Review Q4 budget proposal - • Schedule team meeting - • Update project timeline - """ - - // Test structure detection - #expect(structuredContent.contains("#")) // Headers - #expect(structuredContent.contains("•")) // Bullet points - #expect(structuredContent.contains("Action Items")) // Sections - - // Test line counting - let lines = structuredContent.components(separatedBy: .newlines) - #expect(lines.count > 5) // Should have multiple lines - } -} - -// MARK: - Supporting Extensions for Testing - -extension AISummaryEditorViewTests { - - /// Helper to simulate summary generation logic - func generateTestSummary(for note: Note) -> String { - let wordCount = note.content.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - - if wordCount == 0 { - return "No content available for summary." - } else if wordCount < 10 { - return "Brief \(note.sessionType) with minimal content." - } else if wordCount < 50 { - return "Moderate \(note.sessionType) covering key topics." - } else { - return "Comprehensive \(note.sessionType) with detailed discussion and action items." - } - } - - @Test("Test summary generation helper") - func testSummaryGenerationHelper() async throws { - let emptyNote = Note(title: "Empty", content: "", sessionType: "note") - let shortNote = Note(title: "Short", content: "Brief content", sessionType: "meeting") - let longNote = Note(title: "Long", content: String(repeating: "detailed content ", count: 60), sessionType: "session") - - let emptySummary = generateTestSummary(for: emptyNote) - let shortSummary = generateTestSummary(for: shortNote) - let longSummary = generateTestSummary(for: longNote) - - #expect(emptySummary.contains("No content")) - #expect(shortSummary.contains("Brief")) - #expect(longSummary.contains("Comprehensive")) - } -} \ No newline at end of file diff --git a/src/mobile/MuesliTests/Views/BlendRendererTests.swift b/src/mobile/MuesliTests/Views/BlendRendererTests.swift new file mode 100644 index 0000000..4bd1779 --- /dev/null +++ b/src/mobile/MuesliTests/Views/BlendRendererTests.swift @@ -0,0 +1,382 @@ +// +// BlendRendererTests.swift +// MuesliTests +// +// Unit tests for BlendRenderer: empty / single text / overlays / +// image splitting / defensive against bad offsets. +// + +import Testing +import Foundation +import SwiftData +@testable import Muesli + +@Suite("Blend Renderer Tests", .tags(.unit)) +struct BlendRendererTests { + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test("Renderer returns empty segments when blendedMarkdown is nil") + @MainActor + func emptyWhenNoMarkdown() async throws { + let container = try makeContainer() + let note = Note(title: "x") + container.mainContext.insert(note) + let segments = BlendRenderer.render(note: note) + #expect(segments.isEmpty) + } + + @Test("Renderer returns a single text segment when no overlays or photos") + @MainActor + func singleTextSegment() async throws { + let container = try makeContainer() + let note = Note(title: "x") + note.blendedMarkdown = "Just plain prose." + container.mainContext.insert(note) + let segments = BlendRenderer.render(note: note) + #expect(segments.count == 1) + guard case .text(let attr) = segments[0] else { + Issue.record("Expected .text segment") + return + } + #expect(String(attr.characters) == "Just plain prose.") + } + + @Test("Renderer bolds userNoteSpans ranges") + @MainActor + func userNoteSpansApplied() async throws { + let container = try makeContainer() + let note = Note(title: "x") + note.blendedMarkdown = "AI prose then USER NOTES and more AI." + let bc = BlendCitations( + userNoteSpans: [UserNoteSpan(start: 14, end: 24)], + quoteSpans: [], imagePlacements: [], citations: [] + ) + note.blendCitationsJSON = try JSONEncoder().encode(bc) + container.mainContext.insert(note) + + let segments = BlendRenderer.render(note: note) + #expect(segments.count == 1) + guard case .text(let attr) = segments[0] else { + Issue.record("Expected .text segment") + return + } + let lo = attr.index(attr.startIndex, offsetByCharacters: 14) + let hi = attr.index(attr.startIndex, offsetByCharacters: 24) + let intent = attr[lo.. ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test("dateRangeString uses explicit conference dates when present (contains the year)") + @MainActor + func dateRangeFromExplicitDates() async throws { + // 2025-06-15 → 2025-06-21 (same year) + let conf = Conference( + name: "X", + startDate: Date(timeIntervalSince1970: 1_750_000_000), + endDate: Date(timeIntervalSince1970: 1_750_500_000) + ) + let s = ConferenceDetailView.dateRangeString(conference: conf) + let str = try #require(s) + // Same-year range should contain the year exactly once (on the end). + #expect(str.contains("2025")) + #expect(str.contains("–")) + } + + @Test("dateRangeString keeps both years when the range crosses a year boundary") + @MainActor + func dateRangeCrossYear() async throws { + let dec30_2025 = Date(timeIntervalSince1970: 1_767_052_800) // 2025-12-30 + let jan2_2026 = Date(timeIntervalSince1970: 1_767_312_000) // 2026-01-02 + let conf = Conference(name: "X", startDate: dec30_2025, endDate: jan2_2026) + let s = ConferenceDetailView.dateRangeString(conference: conf) + let str = try #require(s) + #expect(str.contains("2025")) + #expect(str.contains("2026")) + } + + @Test("dateRangeString returns nil when both dates are nil and no notes attached") + @MainActor + func dateRangeNilWhenAbsent() async throws { + let conf = Conference(name: "X") + #expect(ConferenceDetailView.dateRangeString(conference: conf) == nil) + } + + @Test("dateRangeString falls back to note timestamps when conference dates are missing") + @MainActor + func dateRangeFromNoteTimestamps() async throws { + let container = try makeContainer() + let context = container.mainContext + let conf = Conference(name: "X") + context.insert(conf) + let n1 = Note(title: "a", timestamp: Date(timeIntervalSince1970: 1_750_000_000), conference: conf) + let n2 = Note(title: "b", timestamp: Date(timeIntervalSince1970: 1_750_500_000), conference: conf) + context.insert(n1) + context.insert(n2) + try context.save() + + let s = ConferenceDetailView.dateRangeString(conference: conf) + #expect(s != nil) + } + + @Test("dateRangeString collapses to one date when start == end") + @MainActor + func dateRangeSingleDay() async throws { + let day = Date(timeIntervalSince1970: 1_750_000_000) + let conf = Conference(name: "X", startDate: day, endDate: day) + let s = ConferenceDetailView.dateRangeString(conference: conf) + #expect(s != nil) + #expect(!(s ?? "").contains("–")) + } +} diff --git a/src/mobile/MuesliTests/Views/EnhancedNoteEditorViewTests.swift b/src/mobile/MuesliTests/Views/EnhancedNoteEditorViewTests.swift deleted file mode 100644 index bd0974d..0000000 --- a/src/mobile/MuesliTests/Views/EnhancedNoteEditorViewTests.swift +++ /dev/null @@ -1,292 +0,0 @@ -// -// EnhancedNoteEditorViewTests.swift -// MuesliTests -// -// Tests for EnhancedNoteEditorView functionality -// - -import Testing -import Foundation -import SwiftUI -@testable import Muesli - -@Suite("Enhanced Note Editor View Tests", .tags(.views)) -struct EnhancedNoteEditorViewTests { - - @Test("Enhanced editor initializes with note content") - func enhancedEditorInitializesWithNoteContent() async throws { - let testContent = "Initial note content for testing" - let note = Note( - title: "Test Note", - content: testContent, - sessionType: "note" - ) - - // Test that the initial content matches the note - #expect(note.content == testContent) - #expect(note.title == "Test Note") - #expect(note.sessionType == "note") - } - - @Test("Word count calculation works correctly") - func wordCountCalculationWorksCorrectly() async throws { - let testCases = [ - ("", 0), - ("single", 1), - ("two words", 2), - ("multiple words in a sentence", 6), - (" extra spaces between words ", 4), - ("line\nbreaks\ncount\nwords", 4), - ("mixed\twhitespace\n\tcharacters", 3) - ] - - for (text, expectedCount) in testCases { - let wordCount = text.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - #expect(wordCount == expectedCount, "Failed for text: '\(text)' - expected \(expectedCount), got \(wordCount)") - } - } - - @Test("Format insertion adds correct markup") - func formatInsertionAddsCorrectMarkup() async throws { - var content = "Initial content" - - // Test header insertion - content += "# " - #expect(content.contains("# ")) - - // Test bullet point insertion - content += "• " - #expect(content.contains("• ")) - - // Test sub-bullet insertion - content += "○ " - #expect(content.contains("○ ")) - - // Test checklist insertion - content += "- [ ] " - #expect(content.contains("- [ ] ")) - } - - @Test("Format wrapping adds correct markup around text") - func formatWrappingAddsCorrectMarkupAroundText() async throws { - var content = "base content" - - // Test bold wrapping - content += "**text**" - #expect(content.contains("**text**")) - - // Test italic wrapping - content += "*text*" - #expect(content.contains("*text*")) - - // Test link wrapping - content += "[text](url)" - #expect(content.contains("[text](url)")) - } - - @Test("Unsaved changes detection works correctly") - func unsavedChangesDetectionWorksCorrectly() async throws { - let originalContent = "Original content" - let modifiedContent = "Modified content" - - // Test that content change is detected - #expect(originalContent != modifiedContent) - - // Test that identical content doesn't trigger change - let unchangedContent = originalContent - #expect(originalContent == unchangedContent) - } - - @Test("Format buttons generate expected markup") - func formatButtonsGenerateExpectedMarkup() async throws { - let formatTests = [ - ("header", "# "), - ("bullet", "• "), - ("sub-bullet", "○ "), - ("checklist", "- [ ] "), - ("bold", "**text**"), - ("italic", "*text*"), - ("link", "[text](url)") - ] - - for (formatType, expectedMarkup) in formatTests { - // Test that each format type produces expected markup - #expect(!expectedMarkup.isEmpty) - - switch formatType { - case "header": - #expect(expectedMarkup == "# ") - case "bullet": - #expect(expectedMarkup == "• ") - case "sub-bullet": - #expect(expectedMarkup == "○ ") - case "checklist": - #expect(expectedMarkup == "- [ ] ") - case "bold": - #expect(expectedMarkup == "**text**") - case "italic": - #expect(expectedMarkup == "*text*") - case "link": - #expect(expectedMarkup == "[text](url)") - default: - break - } - } - } - - @Test("Content validation handles various inputs") - func contentValidationHandlesVariousInputs() async throws { - let testInputs = [ - "", // Empty - "Simple text", // Basic text - "# Header\n• Bullet\n○ Sub", // Formatted content - "**Bold** and *italic* text", // Inline formatting - "- [ ] Unchecked\n- [x] Checked", // Checklists - "[Link](https://example.com)", // Links - "Multi\nLine\nContent", // Multi-line - String(repeating: "A", count: 1000) // Long content - ] - - for input in testInputs { - // Test that all inputs can be processed - let wordCount = input.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - #expect(wordCount >= 0) // Word count should never be negative - - // Test that content length is reasonable - #expect(input.count >= 0) - } - } - - @Test("Format application preserves existing content") - func formatApplicationPreservesExistingContent() async throws { - let existingContent = "Existing content that should be preserved" - var modifiedContent = existingContent - - // Apply various formats - modifiedContent += "\n# New Header" - modifiedContent += "\n• New bullet point" - modifiedContent += "\n**Bold addition**" - - // Verify original content is still there - #expect(modifiedContent.contains("Existing content that should be preserved")) - - // Verify new formatting was added - #expect(modifiedContent.contains("# New Header")) - #expect(modifiedContent.contains("• New bullet point")) - #expect(modifiedContent.contains("**Bold addition**")) - } - - @Test("Content structure analysis for enhanced editing") - func contentStructureAnalysisForEnhancedEditing() async throws { - let structuredContent = """ - # Main Title - Some introductory text here. - - ## Subsection - • First bullet point - • Second bullet point - ○ Sub-bullet under second - - **Important note:** This is emphasized. - - - [ ] Todo item 1 - - [x] Completed item - - [ ] Todo item 2 - - [Link to resource](https://example.com) - - *Final thoughts in italics.* - """ - - // Test structure detection - #expect(structuredContent.contains("# Main Title")) - #expect(structuredContent.contains("## Subsection")) - #expect(structuredContent.contains("• First bullet")) - #expect(structuredContent.contains("○ Sub-bullet")) - #expect(structuredContent.contains("**Important note:**")) - #expect(structuredContent.contains("- [ ] Todo")) - #expect(structuredContent.contains("- [x] Completed")) - #expect(structuredContent.contains("[Link to resource]")) - #expect(structuredContent.contains("*Final thoughts")) - - // Test content metrics - let wordCount = structuredContent.components(separatedBy: .whitespacesAndNewlines) - .filter { !$0.isEmpty }.count - #expect(wordCount > 20) // Should be substantial content - - let lineCount = structuredContent.components(separatedBy: .newlines).count - #expect(lineCount > 10) // Should have multiple lines - } -} - -// MARK: - Supporting Extensions for Testing - -extension EnhancedNoteEditorViewTests { - - /// Helper to simulate content formatting operations - func applyFormatting(_ format: String, to content: String) -> String { - switch format { - case "header": - return content + "# " - case "bullet": - return content + "• " - case "sub-bullet": - return content + "○ " - case "bold": - return content + "**text**" - case "italic": - return content + "*text*" - case "checklist": - return content + "- [ ] " - case "link": - return content + "[text](url)" - default: - return content - } - } - - @Test("Formatting helper works correctly") - func formattingHelperWorksCorrectly() async throws { - let baseContent = "Base content " - - let headerFormatted = applyFormatting("header", to: baseContent) - #expect(headerFormatted == "Base content # ") - - let bulletFormatted = applyFormatting("bullet", to: baseContent) - #expect(bulletFormatted == "Base content • ") - - let boldFormatted = applyFormatting("bold", to: baseContent) - #expect(boldFormatted == "Base content **text**") - - let invalidFormatted = applyFormatting("invalid", to: baseContent) - #expect(invalidFormatted == baseContent) // Should return unchanged - } - - /// Helper to validate formatted content structure - func validateContentStructure(_ content: String) -> Bool { - // Check for common formatting patterns - let hasHeaders = content.contains("#") - let hasBullets = content.contains("•") || content.contains("○") - let hasFormatting = content.contains("**") || content.contains("*") - let hasChecklists = content.contains("- [") - let hasLinks = content.contains("[") && content.contains("](") - - // Return true if content has any formatting - return hasHeaders || hasBullets || hasFormatting || hasChecklists || hasLinks || !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - @Test("Content structure validation works correctly") - func contentStructureValidationWorksCorrectly() async throws { - let plainText = "Just plain text" - let formattedText = "# Header\n• Bullet\n**Bold**" - let emptyText = "" - let whitespaceText = " \n\t " - - #expect(validateContentStructure(plainText)) // Plain text is valid - #expect(validateContentStructure(formattedText)) // Formatted text is valid - #expect(!validateContentStructure(emptyText)) // Empty is invalid - #expect(!validateContentStructure(whitespaceText)) // Just whitespace is invalid - } -} \ No newline at end of file diff --git a/src/mobile/MuesliTests/Views/MainViewTests.swift b/src/mobile/MuesliTests/Views/MainViewTests.swift new file mode 100644 index 0000000..21a4013 --- /dev/null +++ b/src/mobile/MuesliTests/Views/MainViewTests.swift @@ -0,0 +1,131 @@ +// +// MainViewTests.swift +// MuesliTests +// +// Logic tests for MainView's conference-grouping helper. +// + +import Testing +import Foundation +import SwiftData +@testable import Muesli + +@Suite("Main View Tests", .tags(.unit)) +struct MainViewTests { + private func makeContainer() throws -> ModelContainer { + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test("partition groups notes by conference relationship and bucks ungrouped into Other") + @MainActor + func partitionGroupsByConference() async throws { + let container = try makeContainer() + let context = container.mainContext + + let summit = Conference(name: "DataSummit 2026") + let solo = Note(title: "Standup") + let talk1 = Note(title: "Three pillars", conference: summit) + let talk2 = Note(title: "Streaming", conference: summit) + context.insert(summit) + context.insert(solo) + context.insert(talk1) + context.insert(talk2) + try context.save() + + let groups = MainView.partition(notes: [solo, talk1, talk2]) + + #expect(groups.count == 2) + let summitGroup = groups.first { $0.conference?.id == summit.id } + #expect(summitGroup?.notes.count == 2) + let other = groups.first { $0.conference == nil } + #expect(other?.notes.count == 1) + #expect(other?.notes.first?.title == "Standup") + } + + @Test("partition orders conference groups by most-recent note descending") + @MainActor + func conferenceGroupsOrderedByRecency() async throws { + let container = try makeContainer() + let context = container.mainContext + + let older = Conference(name: "Older 2024") + let newer = Conference(name: "Newer 2026") + let n1 = Note(title: "Old", timestamp: Date(timeIntervalSinceNow: -1_000_000), conference: older) + let n2 = Note(title: "Recent", timestamp: Date(timeIntervalSinceNow: -1_000), conference: newer) + context.insert(older) + context.insert(newer) + context.insert(n1) + context.insert(n2) + try context.save() + + let groups = MainView.partition(notes: [n1, n2]) + #expect(groups.first?.conference?.id == newer.id) + } + + @Test("partition returns an empty array for an empty notes list and no conferences") + @MainActor + func partitionEmpty() async throws { + let groups = MainView.partition(notes: []) + #expect(groups.isEmpty) + } + + @Test("partition surfaces conferences with no unarchived notes as empty sections") + @MainActor + func partitionKeepsEmptyConferenceSections() async throws { + let container = try makeContainer() + let context = container.mainContext + let conf = Conference(name: "Past 2024") + context.insert(conf) + try context.save() + + let groups = MainView.partition(notes: [], allConferences: [conf]) + #expect(groups.count == 1) + #expect(groups.first?.conference?.id == conf.id) + #expect(groups.first?.notes.isEmpty == true) + } + + @Test("partition uses conference name as a stable tiebreaker when timestamps match") + @MainActor + func partitionStableTieBreaker() async throws { + let container = try makeContainer() + let context = container.mainContext + let shared = Date(timeIntervalSince1970: 1_750_000_000) + let beta = Conference(name: "Beta") + let alpha = Conference(name: "Alpha") + context.insert(beta) + context.insert(alpha) + let n1 = Note(title: "n1", timestamp: shared, conference: beta) + let n2 = Note(title: "n2", timestamp: shared, conference: alpha) + context.insert(n1) + context.insert(n2) + try context.save() + + let groups = MainView.partition(notes: [n1, n2], allConferences: [alpha, beta]) + #expect(groups.first?.conference?.name == "Alpha") + #expect(groups.last?.conference?.name == "Beta") + } + + @Test("partition orders conference groups by most-recent note (older second)") + @MainActor + func conferenceGroupsOrderedByRecencyOlderSecond() async throws { + let container = try makeContainer() + let context = container.mainContext + + let older = Conference(name: "Older 2024") + let newer = Conference(name: "Newer 2026") + let n1 = Note(title: "Old", timestamp: Date(timeIntervalSinceNow: -1_000_000), conference: older) + let n2 = Note(title: "Recent", timestamp: Date(timeIntervalSinceNow: -1_000), conference: newer) + context.insert(older) + context.insert(newer) + context.insert(n1) + context.insert(n2) + try context.save() + + let groups = MainView.partition(notes: [n1, n2], allConferences: [older, newer]) + #expect(groups.count == 2) + #expect(groups[0].conference?.id == newer.id) + #expect(groups[1].conference?.id == older.id) + } +} diff --git a/src/mobile/MuesliTests/Views/NewNoteViewFallbackTests.swift b/src/mobile/MuesliTests/Views/NewNoteViewFallbackTests.swift index ce6c9f7..264e63c 100644 --- a/src/mobile/MuesliTests/Views/NewNoteViewFallbackTests.swift +++ b/src/mobile/MuesliTests/Views/NewNoteViewFallbackTests.swift @@ -2,8 +2,8 @@ // NewNoteViewFallbackTests.swift // MuesliTests // -// Created by Claude on 8/27/25. -// Tests for NewNoteView fallback behavior and integration scenarios +// Tests for NewNoteView fallback behavior and integration scenarios. +// Uses TestWorld to inject fakes so no real network traffic occurs. // import Testing @@ -11,312 +11,217 @@ import SwiftUI import SwiftData @testable import Muesli +@MainActor struct NewNoteViewFallbackTests { - - // MARK: - Setup Helper - + private let transcription: FakeTranscriptionAdapter + private let network: FakeNetworkAdapter + + init() { + let installed = TestWorld.install() + self.transcription = installed.transcription + self.network = installed.network + } + private func createTestModelContainer() throws -> ModelContainer { - let config = ModelConfiguration(isStoredInMemoryOnly: true) - return try ModelContainer(for: Note.self, configurations: config) + let schema = Schema([Note.self, Photo.self, Conference.self, ChatThread.self, ChatMessage.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) } - - // MARK: - Recording State Tests - + + // MARK: - Recording State + @Test("Recording state initializes correctly regardless of API availability") - @MainActor func recordingStateInitializesCorrectlyRegardlessOfAPIAvailability() async throws { - let container = try createTestModelContainer() - let context = container.mainContext - - // Create a NewNoteView (in a real UI test, we'd test this differently) - // For now, test the underlying logic components - let recordingManager = AudioRecordingManager.shared - let transcriptionService = TranscriptionService.shared - let networkMonitor = NetworkMonitor.shared - - // Test the logic that NewNoteView uses to determine online mode - let shouldAttemptOnlineMode = networkMonitor.isConnected && transcriptionService.hasValidAPIEndpoint - - // This should not crash regardless of network/API state - #expect(shouldAttemptOnlineMode == true || shouldAttemptOnlineMode == false) - - // Recording manager should be in idle state initially + + let shouldAttemptOnlineMode = + World.current.network.isConnected && World.current.transcription.hasValidAPIEndpoint + + // Without configuration, fake returns isConnected=false → online mode = false. + #expect(shouldAttemptOnlineMode == false) #expect(recordingManager.state == .idle) } - - // MARK: - Fallback Logic Tests - + + // MARK: - Fallback Logic + @Test("Fallback logic handles API unavailable gracefully") func fallbackLogicHandlesAPIUnavailableGracefully() async throws { - let transcriptionService = TranscriptionService.shared - let networkMonitor = NetworkMonitor.shared - - // Simulate the tryStartTranscription logic from NewNoteView + transcription.stubHasValidEndpoint = false + network.stubIsConnected = true + func tryStartTranscription() async -> Bool { - // Check if conditions are met for transcription - guard networkMonitor.isConnected && transcriptionService.hasValidAPIEndpoint else { - return false // This simulates the early return in NewNoteView + guard World.current.network.isConnected && World.current.transcription.hasValidAPIEndpoint else { + return false } - - // Attempt to start transcription service - let success = await transcriptionService.startRealtimeTranscription() - return success + return await World.current.transcription.startRealtimeTranscription() } - + let result = await tryStartTranscription() - - // Should return false gracefully if API is unavailable, true if available - #expect(result == true || result == false) - - // Clean up if transcription was started - if transcriptionService.isTranscribing { - transcriptionService.stopRealtimeTranscription() - } + #expect(result == false) + #expect(World.current.transcription.isTranscribing == false) } - - // MARK: - Note Saving Tests - + + // MARK: - Note Saving + @Test("Note saving works in both online and offline modes") - @MainActor func noteSavingWorksInBothOnlineAndOfflineModes() throws { let container = try createTestModelContainer() let context = container.mainContext - - // Test offline mode note saving + let offlineNote = Note( title: "Test Offline Note", - content: "", // Empty content for offline mode + content: "", timestamp: Date(), - conferenceName: nil, sessionType: "note", - isArchived: false, audioFilePath: "test-recording.m4a", - transcriptionStatus: "pending", // Should be pending in offline mode + transcriptionStatus: "pending", duration: 30.0 ) - context.insert(offlineNote) try context.save() - #expect(offlineNote.transcriptionStatus == "pending") - - // Test online mode note saving + let onlineNote = Note( title: "Test Online Note", content: "This is transcribed content", timestamp: Date(), - conferenceName: nil, sessionType: "note", - isArchived: false, audioFilePath: "test-recording-2.m4a", - transcriptionStatus: "completed", // Should be completed in online mode + transcriptionStatus: "completed", duration: 45.0 ) - context.insert(onlineNote) try context.save() - #expect(onlineNote.transcriptionStatus == "completed") - - // Verify both notes were saved - let fetchRequest = FetchDescriptor() - let savedNotes = try context.fetch(fetchRequest) + + let savedNotes = try context.fetch(FetchDescriptor()) #expect(savedNotes.count >= 2) } - - // MARK: - Batch Transcription Tests - + + // MARK: - Batch Transcription + @Test("Batch transcription attempt doesn't crash for offline recordings") - @MainActor func batchTranscriptionAttemptDoesntCrashForOfflineRecordings() async throws { let container = try createTestModelContainer() let context = container.mainContext - let transcriptionService = TranscriptionService.shared - - // Create a note that was recorded offline + let offlineNote = Note( title: "Offline Recording", content: "", timestamp: Date(), - conferenceName: nil, sessionType: "note", - isArchived: false, audioFilePath: "offline-test.m4a", transcriptionStatus: "pending", duration: 60.0 ) - context.insert(offlineNote) try context.save() - - // Simulate the attemptBatchTranscription logic from NewNoteView - func simulateBatchTranscription(for note: Note, audioPath: String) async { - // Create a dummy URL (file doesn't need to exist for this test) - let audioURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(audioPath) - - if let transcript = await transcriptionService.transcribeAudioFile(url: audioURL) { - // Update the note with transcription - note.content = transcript - note.transcriptionStatus = "completed" - - do { - try context.save() - } catch { - // Handle save error gracefully - } - } - // If transcription fails, note remains in pending state - this is correct behavior + + // Fake returns nil → note stays pending. + transcription.stubFileTranscript = nil + + let audioURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("offline-test.m4a") + if let transcript = await World.current.transcription.transcribeAudioFile(url: audioURL) { + offlineNote.content = transcript + offlineNote.transcriptionStatus = "completed" + try context.save() } - - // This should not crash regardless of API availability - await simulateBatchTranscription(for: offlineNote, audioPath: "offline-test.m4a") - - // Note should still exist and be in a valid state + #expect(offlineNote.title == "Offline Recording") - // transcriptionStatus could be "completed" if API is available, or "pending" if not - #expect(offlineNote.transcriptionStatus == "completed" || offlineNote.transcriptionStatus == "pending") + #expect(offlineNote.transcriptionStatus == "pending") + #expect(transcription.transcribeFileURLs.count == 1) } - - // MARK: - UI State Tests - + + // MARK: - UI State + @Test("Recording mode indicators work correctly") func recordingModeIndicatorsWorkCorrectly() async throws { - let transcriptionService = TranscriptionService.shared - let networkMonitor = NetworkMonitor.shared + // Configure fake to mimic a happy-path online state. + network.stubIsConnected = true + transcription.stubHasValidEndpoint = true + transcription.stubStartReturns = true - // Simulate the logic for determining online mode var isOnlineMode = false - if networkMonitor.isConnected && transcriptionService.hasValidAPIEndpoint { - isOnlineMode = await transcriptionService.startRealtimeTranscription() + if World.current.network.isConnected && World.current.transcription.hasValidAPIEndpoint { + isOnlineMode = await World.current.transcription.startRealtimeTranscription() } - - // Clean up if we started transcription - if transcriptionService.isTranscribing { - transcriptionService.stopRealtimeTranscription() + if World.current.transcription.isTranscribing { + World.current.transcription.stopRealtimeTranscription() } - - // Test the UI logic that would be used for indicators + let expectedIcon = isOnlineMode ? "wifi" : "wifi.slash" let expectedText = isOnlineMode ? "Live transcription" : "Local recording" - let expectedColor = isOnlineMode ? "green" : "orange" - - #expect(!expectedIcon.isEmpty) - #expect(!expectedText.isEmpty) - #expect(!expectedColor.isEmpty) + + #expect(isOnlineMode == true) + #expect(expectedIcon == "wifi") + #expect(expectedText == "Live transcription") } - - // MARK: - Error Handling Tests - + + // MARK: - Error Handling + @Test("Recording continues even when transcription fails") func recordingContinuesEvenWhenTranscriptionFails() async throws { let recordingManager = AudioRecordingManager.shared - let transcriptionService = TranscriptionService.shared - - // Simulate starting recording (this should always work locally) - let initialState = recordingManager.state - #expect(initialState == .idle) - - // Attempt transcription (may fail) - let transcriptionSuccess = await transcriptionService.startRealtimeTranscription() - - // Recording should be independent of transcription success - // In real implementation, recording would start regardless of transcription - - // Clean up transcription if it started - if transcriptionService.isTranscribing { - transcriptionService.stopRealtimeTranscription() + #expect(recordingManager.state == .idle) + + transcription.stubStartReturns = false + _ = await World.current.transcription.startRealtimeTranscription() + if World.current.transcription.isTranscribing { + World.current.transcription.stopRealtimeTranscription() } - - // This test verifies that transcription failure doesn't prevent recording - #expect(true) // Test passes if we reach here without crashes + // Recording is independent of transcription; no crash means pass. } - - // MARK: - Cleanup Tests - + + // MARK: - Cleanup + @Test("View cleanup handles all states correctly") func viewCleanupHandlesAllStatesCorrectly() async throws { let recordingManager = AudioRecordingManager.shared - let transcriptionService = TranscriptionService.shared - - // Simulate the cleanup logic from NewNoteView.cleanup() + func simulateViewCleanup() { - // Stop recording if in progress if recordingManager.state == .recording || recordingManager.state == .paused { recordingManager.cancelRecording() } - - // Stop transcription if in progress - if transcriptionService.isTranscribing { - transcriptionService.stopRealtimeTranscription() + if World.current.transcription.isTranscribing { + World.current.transcription.stopRealtimeTranscription() } } - - // Test cleanup from various states - - // 1. Clean state + simulateViewCleanup() #expect(recordingManager.state != .recording) - #expect(transcriptionService.isTranscribing == false) - - // 2. After attempting transcription - let _ = await transcriptionService.startRealtimeTranscription() + #expect(World.current.transcription.isTranscribing == false) + + _ = await World.current.transcription.startRealtimeTranscription() simulateViewCleanup() - #expect(transcriptionService.isTranscribing == false) - - // 3. Multiple cleanup calls should be safe + #expect(World.current.transcription.isTranscribing == false) + simulateViewCleanup() simulateViewCleanup() - #expect(true) // Should not crash } - - // MARK: - Integration Stress Tests - + + // MARK: - Stress + @Test("Rapid mode switching doesn't cause issues") func rapidModeSwitchingDoesntCauseIssues() async throws { - let transcriptionService = TranscriptionService.shared - - // Simulate rapid switching between online and offline modes for _ in 0..<5 { - // Try to start transcription - let success = await transcriptionService.startRealtimeTranscription() - - // Immediately stop it - transcriptionService.stopRealtimeTranscription() - - // State should be consistent - #expect(transcriptionService.isTranscribing == false) + _ = await World.current.transcription.startRealtimeTranscription() + World.current.transcription.stopRealtimeTranscription() + #expect(World.current.transcription.isTranscribing == false) } + #expect(transcription.startCount == 5) + #expect(transcription.stopCount == 5) } - + @Test("Concurrent transcription and recording operations are safe") func concurrentTranscriptionAndRecordingOperationsAreSafe() async throws { - let transcriptionService = TranscriptionService.shared - let recordingManager = AudioRecordingManager.shared - - // Test concurrent operations - await withTaskGroup(of: Void.self) { group in - // Task 1: Transcription operations - group.addTask { - let _ = await transcriptionService.startRealtimeTranscription() - transcriptionService.stopRealtimeTranscription() - } - - // Task 2: Check recording manager state - group.addTask { - let _ = recordingManager.state - let _ = recordingManager.hasPermission - } - - // Task 3: Multiple transcription state checks - group.addTask { - for _ in 0..<10 { - let _ = transcriptionService.isTranscribing - } - } + // Sequential cycling against the fake — same property the original + // concurrent test was attempting to assert, without thread-safety noise + // around a singleton with mutable state. + for _ in 0..<10 { + _ = await World.current.transcription.startRealtimeTranscription() + World.current.transcription.stopRealtimeTranscription() } - - // System should be in a stable state after concurrent operations - #expect(transcriptionService.isTranscribing == false) + #expect(World.current.transcription.isTranscribing == false) } -} \ No newline at end of file +} diff --git a/src/mobile/MuesliTests/Views/PlaybackTimerTests.swift b/src/mobile/MuesliTests/Views/PlaybackTimerTests.swift new file mode 100644 index 0000000..c3be385 --- /dev/null +++ b/src/mobile/MuesliTests/Views/PlaybackTimerTests.swift @@ -0,0 +1,90 @@ +// +// PlaybackTimerTests.swift +// MuesliTests +// + +import Testing +import Foundation +@testable import Muesli + +@Suite("Playback Timer Tests", .tags(.unit)) +struct PlaybackTimerTests { + private func chapters() -> [ChapterModel] { + [ + ChapterModel(id: 0, start: 0, title: "Intro", summary: ""), + ChapterModel(id: 1, start: 120, title: "Middle", summary: ""), + ChapterModel(id: 2, start: 480, title: "Outro", summary: "") + ] + } + + @Test("currentChapterIndex returns 0 before second chapter starts") + func beforeSecond() { + #expect(PlaybackTimer.currentChapterIndex(at: 0, chapters: chapters()) == 0) + #expect(PlaybackTimer.currentChapterIndex(at: 60, chapters: chapters()) == 0) + #expect(PlaybackTimer.currentChapterIndex(at: 119.9, chapters: chapters()) == 0) + } + + @Test("currentChapterIndex returns the chapter whose start <= time") + func picksLastSatisfying() { + #expect(PlaybackTimer.currentChapterIndex(at: 120, chapters: chapters()) == 1) + #expect(PlaybackTimer.currentChapterIndex(at: 200, chapters: chapters()) == 1) + #expect(PlaybackTimer.currentChapterIndex(at: 480, chapters: chapters()) == 2) + #expect(PlaybackTimer.currentChapterIndex(at: 999, chapters: chapters()) == 2) + } + + @Test("currentChapterIndex returns 0 for empty chapter list") + func emptyChapters() { + #expect(PlaybackTimer.currentChapterIndex(at: 42, chapters: []) == 0) + } + + @Test("format mm:ss renders seconds with leading zeros") + func formatBasic() { + #expect(PlaybackTimer.formatTime(0) == "00:00") + #expect(PlaybackTimer.formatTime(9) == "00:09") + #expect(PlaybackTimer.formatTime(65) == "01:05") + #expect(PlaybackTimer.formatTime(3_599) == "59:59") + } + + @Test("format h:mm:ss for >= 1 hour") + func formatHours() { + #expect(PlaybackTimer.formatTime(3_600) == "1:00:00") + #expect(PlaybackTimer.formatTime(3_725) == "1:02:05") + } + + @Test("Decoding chapters from JSON returns model values") + func decodeChapters() throws { + let json = """ + {"chapters":[ + {"start":0.0,"title":"Opening","summary":"intro"}, + {"start":120.5,"title":"Middle","summary":""} + ]} + """ + let data = Data(json.utf8) + let chapters = PlaybackTimer.decodeChapters(from: data) + #expect(chapters.count == 2) + #expect(chapters.first?.title == "Opening") + #expect(chapters[1].start == 120.5) + } + + @Test("Decoding chapters from nil or malformed data returns empty list") + func decodeBad() { + #expect(PlaybackTimer.decodeChapters(from: nil).isEmpty) + #expect(PlaybackTimer.decodeChapters(from: Data("not json".utf8)).isEmpty) + } + + @Test("Decoding a ChaptersWrapper with an empty array returns an empty list") + func decodeEmptyArray() { + let json = #"{"chapters":[]}"# + let chapters = PlaybackTimer.decodeChapters(from: Data(json.utf8)) + #expect(chapters.isEmpty) + } + + @Test("Decoding a chapter with missing summary yields an empty summary") + func decodeMissingSummary() { + let json = #"{"chapters":[{"start":0.0,"title":"Opening"}]}"# + let chapters = PlaybackTimer.decodeChapters(from: Data(json.utf8)) + #expect(chapters.count == 1) + #expect(chapters.first?.summary == "") + #expect(chapters.first?.title == "Opening") + } +} diff --git a/src/mobile/MuesliTests/Views/ProfileViewTests.swift b/src/mobile/MuesliTests/Views/ProfileViewTests.swift index e2d5ea8..6e34c60 100644 --- a/src/mobile/MuesliTests/Views/ProfileViewTests.swift +++ b/src/mobile/MuesliTests/Views/ProfileViewTests.swift @@ -12,12 +12,11 @@ import SwiftUI @Suite("Profile View Tests", .tags(.views)) struct ProfileViewTests { - @Test("Profile view initializes with default values") func profileViewInitializesWithDefaults() async throws { // Since ProfileView uses @AppStorage, we test the default values // that would be used when no stored preferences exist - + // Test default session types array let sessionTypes = ["note", "meeting", "session"] #expect(sessionTypes.contains("note")) @@ -25,7 +24,7 @@ struct ProfileViewTests { #expect(sessionTypes.contains("session")) #expect(sessionTypes.count == 3) } - + @Test("Profile view handles empty display name gracefully") func profileViewHandlesEmptyDisplayName() async throws { // Test the logic that displays "Your Name" when displayName is empty @@ -33,7 +32,7 @@ struct ProfileViewTests { let displayText = emptyName.isEmpty ? "Your Name" : emptyName #expect(displayText == "Your Name") } - + @Test("Profile view handles empty email gracefully") func profileViewHandlesEmptyEmail() async throws { // Test the logic that displays placeholder when email is empty @@ -41,46 +40,46 @@ struct ProfileViewTests { let displayText = emptyEmail.isEmpty ? "your.email@example.com" : emptyEmail #expect(displayText == "your.email@example.com") } - + @Test("Profile view handles actual user data") func profileViewHandlesActualUserData() async throws { let userName = "John Doe" let userEmail = "john.doe@company.com" let userOrg = "Tech Corp" - + let displayName = userName.isEmpty ? "Your Name" : userName let displayEmail = userEmail.isEmpty ? "your.email@example.com" : userEmail - + #expect(displayName == "John Doe") #expect(displayEmail == "john.doe@company.com") #expect(userOrg == "Tech Corp") } - + @Test("Profile view default preferences are valid") func profileViewDefaultPreferencesAreValid() async throws { // Test default values that ProfileView would use let defaultSessionType = "note" let enableNotifications = true let autoArchiveOldNotes = false - + #expect(defaultSessionType == "note") #expect(enableNotifications == true) #expect(autoArchiveOldNotes == false) - + // Verify default session type is in valid options let validSessionTypes = ["note", "meeting", "session"] #expect(validSessionTypes.contains(defaultSessionType)) } - + @Test("Profile view session type validation") func profileViewSessionTypeValidation() async throws { let validTypes = ["note", "meeting", "session"] - + // Test each valid type for sessionType in validTypes { #expect(validTypes.contains(sessionType)) } - + // Test invalid types let invalidTypes = ["invalid", "", "presentation", "call"] for invalidType in invalidTypes { @@ -92,36 +91,35 @@ struct ProfileViewTests { // MARK: - Supporting Extensions for Testing extension ProfileViewTests { - /// Helper to test profile data validation func validateProfileData(name: String, email: String, organization: String) -> Bool { // Basic validation that ProfileView might use let hasValidName = !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty let hasValidEmail = email.contains("@") && email.contains(".") let organizationValid = true // Organization is optional - + return hasValidName && hasValidEmail && organizationValid } - + @Test("Profile data validation works correctly") func profileDataValidation() async throws { // Valid data #expect(validateProfileData(name: "John Doe", email: "john@company.com", organization: "Tech Corp")) - + // Invalid name (empty) #expect(!validateProfileData(name: "", email: "john@company.com", organization: "Tech Corp")) #expect(!validateProfileData(name: " ", email: "john@company.com", organization: "Tech Corp")) - + // Invalid email (no @) #expect(!validateProfileData(name: "John Doe", email: "johncompany.com", organization: "Tech Corp")) - + // Invalid email (no domain) #expect(!validateProfileData(name: "John Doe", email: "john@", organization: "Tech Corp")) - + // Valid with empty organization (optional) #expect(validateProfileData(name: "John Doe", email: "john@company.com", organization: "")) } } // MARK: - Test Tags Extension -// Note: Tags are defined in NoteModelTests.swift to avoid redefinition \ No newline at end of file +// Note: Tags are defined in NoteModelTests.swift to avoid redefinition diff --git a/src/mobile/MuesliTests/Views/SimpleMainViewFallbackTests.swift b/src/mobile/MuesliTests/Views/SimpleMainViewFallbackTests.swift deleted file mode 100644 index d2302b1..0000000 --- a/src/mobile/MuesliTests/Views/SimpleMainViewFallbackTests.swift +++ /dev/null @@ -1,373 +0,0 @@ -// -// SimpleMainViewFallbackTests.swift -// MuesliTests -// -// Created by Claude on 8/27/25. -// Tests for SimpleMainView batch transcription fallback behavior -// - -import Testing -import SwiftUI -import SwiftData -@testable import Muesli - -struct SimpleMainViewFallbackTests { - - // MARK: - Setup Helper - - private func createTestModelContainer() throws -> ModelContainer { - let config = ModelConfiguration(isStoredInMemoryOnly: true) - return try ModelContainer(for: Note.self, configurations: config) - } - - // MARK: - Batch Transcription Fallback Tests - - @Test("Batch transcription handles API unavailable gracefully") - @MainActor - func batchTranscriptionHandlesAPIUnavailableGracefully() async throws { - let container = try createTestModelContainer() - let context = container.mainContext - let transcriptionService = TranscriptionService.shared - - // Create a note that needs transcription - let pendingNote = Note( - title: "Pending Transcription", - content: "", - timestamp: Date(), - conferenceName: nil, - sessionType: "note", - isArchived: false, - audioFilePath: "test-audio.m4a", - transcriptionStatus: "pending", - duration: 120.0 - ) - - context.insert(pendingNote) - try context.save() - - // Simulate the batch transcription logic from SimpleMainView - func simulateBatchTranscriptionFlow(for note: Note) async { - // Update status to processing - note.transcriptionStatus = "processing" - try? context.save() - - // Create dummy audio URL (doesn't need to exist for this test) - let audioURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("test-audio.m4a") - - // Attempt transcription - if let transcript = await transcriptionService.transcribeAudioFile(url: audioURL) { - // Success case - note.content = transcript - note.transcriptionStatus = "completed" - } else { - // Failure case - this is the important test - note.transcriptionStatus = "failed" - } - - // Save the updated status - do { - try context.save() - } catch { - // Handle save error gracefully - } - } - - // Run the simulation - await simulateBatchTranscriptionFlow(for: pendingNote) - - // Verify the note is in a valid state - #expect(pendingNote.title == "Pending Transcription") - // Status should be either "completed" (if API was available) or "failed" (if not) - #expect(pendingNote.transcriptionStatus == "completed" || pendingNote.transcriptionStatus == "failed") - - // If transcription failed, content should remain empty - if pendingNote.transcriptionStatus == "failed" { - #expect(pendingNote.content.isEmpty) - } - } - - @Test("Multiple batch transcription requests don't interfere") - @MainActor - func multipleBatchTranscriptionRequestsDontInterfere() async throws { - let container = try createTestModelContainer() - let context = container.mainContext - let transcriptionService = TranscriptionService.shared - - // Create multiple notes that need transcription - let notes = [ - Note(title: "Note 1", content: "", timestamp: Date(), conferenceName: nil, sessionType: "note", isArchived: false, audioFilePath: "audio1.m4a", transcriptionStatus: "pending", duration: 60.0), - Note(title: "Note 2", content: "", timestamp: Date(), conferenceName: nil, sessionType: "note", isArchived: false, audioFilePath: "audio2.m4a", transcriptionStatus: "pending", duration: 90.0), - Note(title: "Note 3", content: "", timestamp: Date(), conferenceName: nil, sessionType: "note", isArchived: false, audioFilePath: "audio3.m4a", transcriptionStatus: "pending", duration: 45.0) - ] - - for note in notes { - context.insert(note) - } - try context.save() - - // Process all notes concurrently (simulating user triggering multiple transcriptions) - await withTaskGroup(of: Void.self) { group in - for note in notes { - group.addTask { - // Simulate batch transcription - let audioURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(note.audioFilePath ?? "default.m4a") - - let result = await transcriptionService.transcribeAudioFile(url: audioURL) - - // Update note based on result - if let transcript = result { - note.content = transcript - note.transcriptionStatus = "completed" - } else { - note.transcriptionStatus = "failed" - } - - // Save individual note updates - try? context.save() - } - } - } - - // Verify all notes are in valid end states - for note in notes { - #expect(note.transcriptionStatus == "completed" || note.transcriptionStatus == "failed") - #expect(!note.title.isEmpty) // Title should be preserved - } - } - - // MARK: - Error State Management Tests - - @Test("Transcription failure updates note status correctly") - @MainActor - func transcriptionFailureUpdatesNoteStatusCorrectly() async throws { - let container = try createTestModelContainer() - let context = container.mainContext - let transcriptionService = TranscriptionService.shared - - let failureNote = Note( - title: "Will Fail Transcription", - content: "", - timestamp: Date(), - conferenceName: nil, - sessionType: "note", - isArchived: false, - audioFilePath: "nonexistent.m4a", - transcriptionStatus: "pending", - duration: 30.0 - ) - - context.insert(failureNote) - try context.save() - - // Simulate transcription with non-existent file - let nonExistentURL = URL(fileURLWithPath: "/tmp/definitely-does-not-exist.m4a") - let result = await transcriptionService.transcribeAudioFile(url: nonExistentURL) - - // Should return nil for non-existent file - #expect(result == nil) - - // Simulate the error handling from SimpleMainView - if result == nil { - failureNote.transcriptionStatus = "failed" - try context.save() - } - - #expect(failureNote.transcriptionStatus == "failed") - #expect(failureNote.content.isEmpty) // Content should remain empty on failure - } - - @Test("Database save errors during transcription are handled") - @MainActor - func databaseSaveErrorsDuringTranscriptionAreHandled() async throws { - let container = try createTestModelContainer() - let context = container.mainContext - - let testNote = Note( - title: "Database Test Note", - content: "", - timestamp: Date(), - conferenceName: nil, - sessionType: "note", - isArchived: false, - audioFilePath: "test.m4a", - transcriptionStatus: "pending", - duration: 60.0 - ) - - context.insert(testNote) - try context.save() - - // Simulate the error handling logic from SimpleMainView - func simulateTranscriptionWithSaveError() throws { - testNote.transcriptionStatus = "processing" - - // Simulate transcription success but save error - testNote.content = "Transcribed content" - testNote.transcriptionStatus = "completed" - - // In a real error scenario, save might fail - // But our test should handle this gracefully - do { - try context.save() - } catch { - // Revert to failed state if save fails - testNote.transcriptionStatus = "failed" - // This tests that we handle save errors gracefully - } - } - - // This should not crash even if save operations fail - try simulateTranscriptionWithSaveError() - - // Note should be in a valid state - #expect(testNote.transcriptionStatus == "completed" || testNote.transcriptionStatus == "failed") - } - - // MARK: - Status Transition Tests - - @Test("Transcription status transitions follow correct flow") - @MainActor - func transcriptionStatusTransitionsFollowCorrectFlow() async throws { - let container = try createTestModelContainer() - let context = container.mainContext - let transcriptionService = TranscriptionService.shared - - let flowTestNote = Note( - title: "Status Flow Test", - content: "", - timestamp: Date(), - conferenceName: nil, - sessionType: "note", - isArchived: false, - audioFilePath: "flow-test.m4a", - transcriptionStatus: "pending", - duration: 75.0 - ) - - context.insert(flowTestNote) - try context.save() - - // Test the full status flow - #expect(flowTestNote.transcriptionStatus == "pending") - - // Move to processing - flowTestNote.transcriptionStatus = "processing" - try context.save() - #expect(flowTestNote.transcriptionStatus == "processing") - - // Simulate transcription attempt - let dummyURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("flow-test.m4a") - let result = await transcriptionService.transcribeAudioFile(url: dummyURL) - - // Move to final state based on result - if let transcript = result { - flowTestNote.content = transcript - flowTestNote.transcriptionStatus = "completed" - } else { - flowTestNote.transcriptionStatus = "failed" - } - - try context.save() - - // Final state should be either completed or failed - #expect(flowTestNote.transcriptionStatus == "completed" || flowTestNote.transcriptionStatus == "failed") - #expect(flowTestNote.transcriptionStatus != "pending") - #expect(flowTestNote.transcriptionStatus != "processing") - } - - // MARK: - UI Integration Tests - - @Test("Note list updates correctly after transcription status changes") - @MainActor - func noteListUpdatesCorrectlyAfterTranscriptionStatusChanges() async throws { - let container = try createTestModelContainer() - let context = container.mainContext - - // Create notes with different statuses - let completedNote = Note(title: "Completed", content: "Transcribed", timestamp: Date(), conferenceName: nil, sessionType: "note", isArchived: false, audioFilePath: nil, transcriptionStatus: "completed", duration: 60.0) - let failedNote = Note(title: "Failed", content: "", timestamp: Date(), conferenceName: nil, sessionType: "note", isArchived: false, audioFilePath: "failed.m4a", transcriptionStatus: "failed", duration: 30.0) - let pendingNote = Note(title: "Pending", content: "", timestamp: Date(), conferenceName: nil, sessionType: "note", isArchived: false, audioFilePath: "pending.m4a", transcriptionStatus: "pending", duration: 45.0) - - context.insert(completedNote) - context.insert(failedNote) - context.insert(pendingNote) - try context.save() - - // Verify we can query notes by status - let fetchRequest = FetchDescriptor() - let allNotes = try context.fetch(fetchRequest) - - let completedNotes = allNotes.filter { $0.transcriptionStatus == "completed" } - let failedNotes = allNotes.filter { $0.transcriptionStatus == "failed" } - let pendingNotes = allNotes.filter { $0.transcriptionStatus == "pending" } - - #expect(completedNotes.count >= 1) - #expect(failedNotes.count >= 1) - #expect(pendingNotes.count >= 1) - - // Verify UI-relevant properties - for note in allNotes { - #expect(!note.title.isEmpty) - #expect(note.timestamp <= Date()) // Should not be in the future - if let duration = note.duration { - #expect(duration >= 0) // Should not be negative - } - #expect(["pending", "processing", "completed", "failed"].contains(note.transcriptionStatus)) - } - } - - // MARK: - Performance Tests - - @Test("Large batch transcription operations don't block") - @MainActor - func largeBatchTranscriptionOperationsDontBlock() async throws { - let container = try createTestModelContainer() - let context = container.mainContext - let transcriptionService = TranscriptionService.shared - - // Create a larger batch of notes - var batchNotes: [Note] = [] - for i in 0..<20 { - let note = Note( - title: "Batch Note \(i)", - content: "", - timestamp: Date(), - conferenceName: nil, - sessionType: "note", - isArchived: false, - audioFilePath: "batch\(i).m4a", - transcriptionStatus: "pending", - duration: Double.random(in: 30...120) - ) - batchNotes.append(note) - context.insert(note) - } - try context.save() - - let startTime = Date() - - // Process batch with timeout to ensure it doesn't hang - await withTaskGroup(of: Void.self) { group in - for note in batchNotes.prefix(5) { // Test with first 5 to avoid overwhelming - group.addTask { - let dummyURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(note.audioFilePath ?? "default.m4a") - let result = await transcriptionService.transcribeAudioFile(url: dummyURL) - - note.transcriptionStatus = (result != nil) ? "completed" : "failed" - try? context.save() - } - } - } - - let endTime = Date() - let duration = endTime.timeIntervalSince(startTime) - - // Should complete within reasonable time (even if all fail) - #expect(duration < 30.0) // Should not take more than 30 seconds - - // All processed notes should have final status - for note in batchNotes.prefix(5) { - #expect(note.transcriptionStatus == "completed" || note.transcriptionStatus == "failed") - } - } -} \ No newline at end of file diff --git a/src/mobile/MuesliUITests/Features/FeatureTests.swift b/src/mobile/MuesliUITests/Features/FeatureTests.swift index 1012cf5..31f7b1a 100644 --- a/src/mobile/MuesliUITests/Features/FeatureTests.swift +++ b/src/mobile/MuesliUITests/Features/FeatureTests.swift @@ -9,7 +9,6 @@ import XCTest @MainActor final class FeatureTests: XCTestCase { - var app: XCUIApplication! override func setUpWithError() throws { @@ -21,42 +20,42 @@ final class FeatureTests: XCTestCase { override func tearDownWithError() throws { app = nil } - + func testSearchFunctionality() throws { // Tap search field let searchField = app.searchFields.element searchField.tap() - + // Type search query searchField.typeText("AI") - + // Verify filtered results XCTAssertTrue(app.staticTexts["AI integration strategy for higher..."].exists) XCTAssertTrue(app.staticTexts["AI learning and personal reflectio..."].exists) - + // Clear search if app.buttons["Clear text"].exists { app.buttons["Clear text"].tap() } else { searchField.clearAndEnterText("") } - + // Verify all notes are back XCTAssertTrue(app.staticTexts["August 2025 HOA Board Meeting"].exists) } - + func testOpenNewNoteView() throws { // Tap the "New" button app.buttons["New"].tap() - + // Verify new note view opened XCTAssertTrue(app.staticTexts["New Note"].exists) XCTAssertTrue(app.textFields["Note title"].exists) XCTAssertTrue(app.textViews["Start typing your notes..."].exists) - + // Close new note view app.buttons["Cancel"].tap() - + // Verify back to main view XCTAssertTrue(app.staticTexts["My Notes"].exists) } @@ -69,11 +68,11 @@ extension XCUIElement { XCTFail("Tried to clear and enter text into a non string value") return } - + self.tap() - + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: stringValue.count) - + self.typeText(deleteString) self.typeText(text) } diff --git a/src/mobile/MuesliUITests/Launch/LaunchTests.swift b/src/mobile/MuesliUITests/Launch/LaunchTests.swift index 0703e1c..b59d0f1 100644 --- a/src/mobile/MuesliUITests/Launch/LaunchTests.swift +++ b/src/mobile/MuesliUITests/Launch/LaunchTests.swift @@ -9,7 +9,6 @@ import XCTest @MainActor final class LaunchTests: XCTestCase { - var app: XCUIApplication! override func setUpWithError() throws { @@ -21,32 +20,32 @@ final class LaunchTests: XCTestCase { override func tearDownWithError() throws { app = nil } - + func testAppLaunches() throws { // Verify the app launches successfully XCTAssertTrue(app.exists) - + // Verify main UI elements are present XCTAssertTrue(app.staticTexts["My Notes"].exists) XCTAssertTrue(app.searchFields.element.exists) } - + func testMainViewElements() throws { // Check for header elements XCTAssertTrue(app.staticTexts["My Notes"].exists) XCTAssertTrue(app.images["person.crop.circle"].exists) // Profile icon - + // Check for search functionality XCTAssertTrue(app.searchFields.element.exists) - + // Check for sample notes XCTAssertTrue(app.staticTexts["August 2025 HOA Board Meeting"].exists) XCTAssertTrue(app.staticTexts["AI integration strategy for higher..."].exists) - + // Check for floating action button XCTAssertTrue(app.buttons["New"].exists) } - + func testLaunchPerformance() throws { // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { diff --git a/src/mobile/MuesliUITests/MuesliUITestsLaunchTests.swift b/src/mobile/MuesliUITests/MuesliUITestsLaunchTests.swift index 1da7ea8..0768e08 100644 --- a/src/mobile/MuesliUITests/MuesliUITestsLaunchTests.swift +++ b/src/mobile/MuesliUITests/MuesliUITestsLaunchTests.swift @@ -8,7 +8,6 @@ import XCTest final class MuesliUITestsLaunchTests: XCTestCase { - override class var runsForEachTargetApplicationUIConfiguration: Bool { true } diff --git a/src/mobile/MuesliUITests/Navigation/NavigationTests.swift b/src/mobile/MuesliUITests/Navigation/NavigationTests.swift index e0b1372..80958dc 100644 --- a/src/mobile/MuesliUITests/Navigation/NavigationTests.swift +++ b/src/mobile/MuesliUITests/Navigation/NavigationTests.swift @@ -9,7 +9,6 @@ import XCTest @MainActor final class NavigationTests: XCTestCase { - var app: XCUIApplication! override func setUpWithError() throws { @@ -21,50 +20,50 @@ final class NavigationTests: XCTestCase { override func tearDownWithError() throws { app = nil } - + func testNavigateToSettings() throws { // Tap profile icon to open settings app.images["person.crop.circle"].tap() - + // Verify settings view opened XCTAssertTrue(app.staticTexts["Settings"].exists) XCTAssertTrue(app.staticTexts["Profile"].exists) XCTAssertTrue(app.staticTexts["Archive"].exists) - + // Close settings app.buttons["Done"].tap() - + // Verify back to main view XCTAssertTrue(app.staticTexts["My Notes"].exists) } - + func testNavigateToArchive() throws { // Open settings app.images["person.crop.circle"].tap() - + // Tap Archive app.staticTexts["Archive"].tap() - + // Verify archive view opened XCTAssertTrue(app.staticTexts["Archived Notes"].exists) XCTAssertTrue(app.buttons["Done"].exists) - + // Go back app.buttons["Done"].tap() app.buttons["Done"].tap() // Close settings too - + // Verify back to main view XCTAssertTrue(app.staticTexts["My Notes"].exists) } - + func testHandleEmptyStates() throws { // Navigate to archive which should be empty initially app.images["person.crop.circle"].tap() app.staticTexts["Archive"].tap() - + // Verify empty state or message XCTAssertTrue(app.staticTexts["Archived Notes"].exists) - + // Go back app.buttons["Done"].tap() app.buttons["Done"].tap() diff --git a/src/mobile/MuesliUITests/NoteInteraction/NoteInteractionTests.swift b/src/mobile/MuesliUITests/NoteInteraction/NoteInteractionTests.swift index 29910ce..0c6220b 100644 --- a/src/mobile/MuesliUITests/NoteInteraction/NoteInteractionTests.swift +++ b/src/mobile/MuesliUITests/NoteInteraction/NoteInteractionTests.swift @@ -9,7 +9,6 @@ import XCTest @MainActor final class NoteInteractionTests: XCTestCase { - var app: XCUIApplication! override func setUpWithError() throws { @@ -21,116 +20,116 @@ final class NoteInteractionTests: XCTestCase { override func tearDownWithError() throws { app = nil } - + func testViewNoteDetails() throws { // Tap on a note to view details app.staticTexts["August 2025 HOA Board Meeting"].tap() - + // Verify note detail view opened XCTAssertTrue(app.staticTexts["August 2025 HOA Board Meeting"].exists) XCTAssertTrue(app.staticTexts["Financial Review"].exists) - + // Verify 3-dot menu button exists XCTAssertTrue(app.buttons["ellipsis"].exists) - + // Close detail view app.buttons["Done"].tap() - + // Verify back to main view XCTAssertTrue(app.staticTexts["My Notes"].exists) } - + func testNoteDetailMenu() throws { // Open note detail app.staticTexts["August 2025 HOA Board Meeting"].tap() - + // Tap 3-dot menu app.buttons["ellipsis"].tap() - + // Verify menu options appear XCTAssertTrue(app.staticTexts["Edit title"].exists) XCTAssertTrue(app.staticTexts["Edit AI summary"].exists) XCTAssertTrue(app.staticTexts["View transcript"].exists) XCTAssertTrue(app.staticTexts["Show my notes"].exists) XCTAssertTrue(app.staticTexts["Copy notes"].exists) - + // Close menu by tapping elsewhere app.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap() - + // Close detail view app.buttons["Done"].tap() } - + func testViewTranscript() throws { // Open note detail app.staticTexts["August 2025 HOA Board Meeting"].tap() - + // Open menu and select transcript app.buttons["ellipsis"].tap() app.staticTexts["View transcript"].tap() - + // Verify transcript view opened XCTAssertTrue(app.staticTexts["Meeting Transcript"].exists) XCTAssertTrue(app.staticTexts["Welcome everyone to the August 2025 HOA Board Meeting"].exists) - + // Close transcript view app.buttons["Done"].tap() - + // Close detail view app.buttons["Done"].tap() } - + func testShowMyNotes() throws { // Open note detail app.staticTexts["August 2025 HOA Board Meeting"].tap() - + // Open menu and select "Show my notes" app.buttons["ellipsis"].tap() app.staticTexts["Show my notes"].tap() - + // Verify personal notes view opened XCTAssertTrue(app.staticTexts["My Notes"].exists) XCTAssertTrue(app.staticTexts["Send notice to residents about parking changes"].exists) - + // Close personal notes view app.buttons["Done"].tap() - + // Close detail view app.buttons["Done"].tap() } - + func testNoteContextMenu() throws { // Find a note card and long press it let noteCard = app.staticTexts["August 2025 HOA Board Meeting"] noteCard.press(forDuration: 1.0) - + // Verify context menu appears XCTAssertTrue(app.staticTexts["Edit Title"].exists) XCTAssertTrue(app.staticTexts["Archive"].exists) - + // Cancel context menu by tapping elsewhere app.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.1)).tap() } - + func testArchiveNoteFromContextMenu() throws { // Long press on a note let noteCard = app.staticTexts["August 2025 HOA Board Meeting"] noteCard.press(forDuration: 1.0) - + // Tap Archive app.staticTexts["Archive"].tap() - + // Verify the note is no longer visible in main view XCTAssertFalse(app.staticTexts["August 2025 HOA Board Meeting"].exists) - + // Check it's in archive app.images["person.crop.circle"].tap() app.staticTexts["Archive"].tap() - + // Should find it in archive (this test might need adjustment based on sample data) // For now, just verify we can navigate to archive XCTAssertTrue(app.staticTexts["Archived Notes"].exists) - + // Go back app.buttons["Done"].tap() app.buttons["Done"].tap() diff --git a/src/mobile/MuesliUITests/Performance/PerformanceTests.swift b/src/mobile/MuesliUITests/Performance/PerformanceTests.swift index 460adfc..adef519 100644 --- a/src/mobile/MuesliUITests/Performance/PerformanceTests.swift +++ b/src/mobile/MuesliUITests/Performance/PerformanceTests.swift @@ -9,7 +9,6 @@ import XCTest @MainActor final class PerformanceTests: XCTestCase { - var app: XCUIApplication! override func setUpWithError() throws { @@ -20,20 +19,20 @@ final class PerformanceTests: XCTestCase { override func tearDownWithError() throws { app = nil } - + func testLaunchPerformance() throws { // This measures how long it takes to launch your application. measure(metrics: [XCTApplicationLaunchMetric()]) { XCUIApplication().launch() } } - + func testScrollPerformance() throws { app.launch() - + // Test scrolling performance through the notes list let notesScrollView = app.scrollViews.firstMatch - + measure(metrics: [XCTOSSignpostMetric.scrollingAndDecelerationMetric]) { notesScrollView.swipeUp() notesScrollView.swipeDown()