Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,38 @@ All notable changes to TaleWeaver will be documented in this file.
## TODO – Next Steps (Scenes & AI integration) – 2025-05-18

### 0 Delete Capabilty
- Add ability in UI )to delete scenes, stories, and user characters.
- [x] Add ability in UI to delete scenes, stories, and user characters.

### 1 Scene Workflow
- **SceneListView** refreshes after save; remaining
- [x] **SceneListView** refreshes after save; remaining
- Optional drag-to-reorder scenes
- **SceneDetailView**
- [x] **SceneDetailView**
1. Toolbar “Add Character” opens picker and inserts system prompt “<Name> enters the scene>”.
2. Show active-character chips / avatars at top.
3. Ability to remove a character (optional).
- **SceneEditorView**
- [x] **SceneEditorView**
- Multi-select character assignment when creating / editing a scene.

### 2 Character Workflow
- **StoryCharacterListView**
- [x] **StoryCharacterListView**
- When both user & story sections are empty show placeholder with “+ Add Character” button.

### 3 AI / OpenAI
- Surface `OpenAIError.apiError(message)` to user.
- Add dedicated `generateSceneDescription(theme:)` wrapper (currently re-uses `generateStory`).
- [x] Surface `OpenAIError.apiError(message)` to user.
- [x] Add dedicated `generateSceneDescription(theme:)` wrapper (currently re-uses `generateStory`).

### 4 Core Data / Migration
- Optional debug toggle: wipe store automatically when migration fails (dev builds only).
- Consider background context for chat message inserts to avoid UI hitch.
- [x] Optional debug toggle: wipe store automatically when migration fails (dev builds only).
- [x] Consider background context for chat message inserts to avoid UI hitch.

### 5 UI & Accessibility
- Resolve system keyboard accessory AutoLayout warnings.
- Dark-mode review of new views.
- [x] Resolve system keyboard accessory AutoLayout warnings.
- [x] Dark-mode review of new views.

### 6 Cleanup / Tests
- Remove unused chat code from StoryDetailView.
- Extract `ChatMessageView` into its own file.
- Unit tests for `SceneRepository` and `SceneViewModel`.
- [x] Remove unused chat code from StoryDetailView.
- [x] Extract `ChatMessageView` into its own file.
- [x] Unit tests for `SceneRepository` and `SceneViewModel`.

## [1.0.0] - 2025-05-15
### Added
Expand Down
72 changes: 72 additions & 0 deletions TaleWeaver/UI/ChatMessageView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import SwiftUI

/// Displays a single chat message with optional character avatar.
struct ChatMessageView: View {
let prompt: StoryPrompt
let userCharacter: Character?

var body: some View {
HStack(alignment: .top) {
if let userCharacter = userCharacter,
let avatarURL = userCharacter.avatarURL,
!avatarURL.isEmpty,
let url = URL(string: avatarURL) {
AsyncImage(url: url) { image in
image
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
.clipShape(Circle())
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 40, height: 40)
.overlay(
Text(String(userCharacter.name?.prefix(1) ?? "U"))
.font(.headline)
.foregroundColor(.gray)
)
}
} else {
Circle()
.fill(Color.blue.opacity(0.2))
.frame(width: 40, height: 40)
.overlay(
Text("U")
.font(.headline)
.foregroundColor(.blue)
)
}

VStack(alignment: .leading, spacing: 4) {
Text(userCharacter?.name ?? "User")
.font(.subheadline)
.fontWeight(.semibold)

Text(prompt.promptText ?? "")
.font(.body)
.padding(12)
.background(Color.blue.opacity(0.1))
.cornerRadius(12)

Text(prompt.createdAt ?? Date(), style: .time)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
.accessibilityLabel("Message from \(userCharacter?.name ?? "User"): \(prompt.promptText ?? "")")
}
}

#Preview {
let ctx = PersistenceController.preview.container.viewContext
let story = Story(context: ctx)
let prompt = StoryPrompt(context: ctx)
prompt.promptText = "Hello world"
prompt.createdAt = Date()
return ChatMessageView(prompt: prompt, userCharacter: story.userCharacter)
.environment(\.managedObjectContext, ctx)
}
60 changes: 0 additions & 60 deletions TaleWeaver/UI/StoryDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,66 +99,6 @@ struct StoryDetailView: View {
}
}

// MARK: - ChatMessageView used by SceneDetailView
struct ChatMessageView: View {
let prompt: StoryPrompt
let userCharacter: Character?

var body: some View {
HStack(alignment: .top) {
// Avatar or placeholder
if let userCharacter = userCharacter,
let avatarURL = userCharacter.avatarURL,
!avatarURL.isEmpty {
AsyncImage(url: URL(string: avatarURL)) { image in
image
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
.clipShape(Circle())
} placeholder: {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 40, height: 40)
.overlay(
Text(String((userCharacter.name?.prefix(1) ?? "U")))
.font(.headline)
.foregroundColor(.gray)
)
}
} else {
Circle()
.fill(Color.blue.opacity(0.2))
.frame(width: 40, height: 40)
.overlay(
Text("U")
.font(.headline)
.foregroundColor(.blue)
)
}

VStack(alignment: .leading, spacing: 4) {
Text(userCharacter?.name ?? "User")
.font(.subheadline)
.fontWeight(.semibold)

Text(prompt.promptText ?? "")
.font(.body)
.padding(12)
.background(Color.blue.opacity(0.1))
.cornerRadius(12)

Text(prompt.createdAt ?? Date(), style: .time)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
.accessibilityLabel("Message from \(userCharacter?.name ?? "User"): \(prompt.promptText ?? "")")
}
}

// MARK: - Preview
#Preview {
Expand Down
60 changes: 60 additions & 0 deletions TaleWeaverTests/SceneRepositoryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import XCTest
import CoreData
@testable import TaleWeaver

final class SceneRepositoryTests: XCTestCase {
var persistence: PersistenceController!
var context: NSManagedObjectContext!
var repository: SceneRepository!
var story: Story!

override func setUp() {
super.setUp()
persistence = PersistenceController(inMemory: true)
context = persistence.container.viewContext
repository = SceneRepository(context: context)
story = Story(context: context)
story.id = UUID()
story.title = "Story"
}

override func tearDown() {
persistence = nil
context = nil
repository = nil
story = nil
super.tearDown()
}

func testCreateAndFetchScene() throws {
_ = repository.createScene(for: story, title: "Scene", summary: nil)
try repository.save()

let scenes = try repository.fetchScenes(for: story)
XCTAssertEqual(scenes.count, 1)
XCTAssertEqual(scenes.first?.title, "Scene")
}

func testUpdateScene() throws {
let scene = repository.createScene(for: story, title: "A", summary: "B")
try repository.save()

repository.updateScene(scene, title: "New", summary: "C")
try repository.save()

let fetched = try repository.fetchScenes(for: story)
XCTAssertEqual(fetched.first?.title, "New")
XCTAssertEqual(fetched.first?.summary, "C")
}

func testDeleteScene() throws {
let scene = repository.createScene(for: story, title: "Temp", summary: nil)
try repository.save()

repository.deleteScene(scene)
try repository.save()

let scenes = try repository.fetchScenes(for: story)
XCTAssertTrue(scenes.isEmpty)
}
}
57 changes: 57 additions & 0 deletions TaleWeaverTests/SceneViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import XCTest
import CoreData
@testable import TaleWeaver

final class SceneViewModelTests: XCTestCase {
var persistence: PersistenceController!
var context: NSManagedObjectContext!
var repository: SceneRepository!
var story: Story!
var viewModel: SceneViewModel!

override func setUp() {
super.setUp()
persistence = PersistenceController(inMemory: true)
context = persistence.container.viewContext
repository = SceneRepository(context: context)
story = Story(context: context)
story.id = UUID()
story.title = "Story"
viewModel = SceneViewModel(story: story, repository: repository)
}

override func tearDown() {
persistence = nil
context = nil
repository = nil
story = nil
viewModel = nil
super.tearDown()
}

func testAddScene() {
let scene = viewModel.addScene(title: "One", summary: nil)
XCTAssertEqual(viewModel.scenes.count, 1)
XCTAssertEqual(scene.title, "One")
}

func testUpdateScene() {
let scene = viewModel.addScene(title: "Old", summary: nil)
viewModel.updateScene(scene, title: "New", summary: "Sum")
XCTAssertEqual(viewModel.scenes.first?.title, "New")
XCTAssertEqual(viewModel.scenes.first?.summary, "Sum")
}

func testDeleteScene() {
let scene = viewModel.addScene(title: "Temp", summary: nil)
viewModel.deleteScene(scene)
XCTAssertTrue(viewModel.scenes.isEmpty)
}

func testMoveScenes() {
_ = viewModel.addScene(title: "A", summary: nil)
_ = viewModel.addScene(title: "B", summary: nil)
viewModel.moveScenes(from: IndexSet(integer: 0), to: 1)
XCTAssertEqual(viewModel.scenes.first?.title, "B")
}
}