diff --git a/AGENTS.md b/AGENTS.md index 8e6eaa8..c8819e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## Project -LoopLine is a native SwiftUI iOS/iPadOS knitting companion app Prioritize a small, stable App Store release. Choose simple, practical solutions over speculative or overly abstract architecture. +LoopLine is a native SwiftUI iOS/iPadOS knitting companion app. Choose simple, practical solutions over speculative or overly abstract architecture. ## Architecture - Use SwiftUI for the UI. @@ -59,7 +59,6 @@ Do not introduce elaborate test infrastructure, mock frameworks, repositories, o - Never work directly on `main` or `master`. - The developer creates and checks out the feature branch before Codex starts work. - Keep each task limited to one reviewable purpose. -- Codex may create a local commit only when explicitly asked. - Do not push, merge pull requests, change branch protection, or modify CI configuration unless explicitly asked. - Do not modify secrets, signing credentials, provisioning profiles, or App Store Connect settings. @@ -68,15 +67,6 @@ When creating a commit: - Use a concise, imperative commit message. - Do not commit generated build output, DerivedData, credentials, or personal Xcode user data. -## Documentation -Update documentation only when the requested work changes: -- MVP scope -- User-visible behavior -- Architecture or data model -- Setup, test, or release steps - -Keep documentation concise and consistent with the actual implementation. - ## Completion Report At the end of each task, report: - What changed diff --git a/Localizable.xcstrings b/Localizable.xcstrings index ffc22fb..4cf75b9 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -373,6 +373,42 @@ } } }, + "Delete" : { + "comment" : "A button that deletes a note.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Löschen" + } + } + } + }, + "Delete Note" : { + "comment" : "The title of a confirmation alert that deletes a note.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Notiz löschen" + } + } + } + }, + "Delete Note?" : { + "comment" : "The title of a confirmation alert that deletes a note.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Notiz löschen?" + } + } + } + }, "Delete Project" : { "comment" : "The button that deletes a project.", "isCommentAutoGenerated" : true, @@ -421,6 +457,18 @@ } } }, + "Edit Note" : { + "comment" : "The title of the screen that allows the user to edit a note.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Notiz bearbeiten" + } + } + } + }, "Edit Project" : { "comment" : "A button that opens a screen to edit a project.", "isCommentAutoGenerated" : true, @@ -723,6 +771,18 @@ } } }, + "Note actions" : { + "comment" : "A menu that appears when tapping the ellipsis button in a note.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Notizaktionen" + } + } + } + }, "Notes & Reminders" : { "comment" : "Title of a section that displays notes and reminders for a project.", "isCommentAutoGenerated" : true, @@ -1255,6 +1315,16 @@ } } }, + "Save Changes" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Änderungen sichern" + } + } + } + }, "Save Note" : { "comment" : "The button that saves a note.", "isCommentAutoGenerated" : true, @@ -1443,6 +1513,18 @@ } } }, + "This will permanently delete this note. This cannot be undone." : { + "comment" : "Confirmation alert message for deleting a note.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Diese Notiz wird dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden." + } + } + } + }, "This will reset the current row, repeat, and stitch to 0." : { "comment" : "The body of a confirmation alert that appears when the user taps the \"Reset Counters\" button in the Reading Mode view.", "isCommentAutoGenerated" : true, @@ -1547,4 +1629,4 @@ } }, "version" : "1.3" -} +} \ No newline at end of file diff --git a/LoopLine/ViewModels/ProjectDetailViewModel.swift b/LoopLine/ViewModels/ProjectDetailViewModel.swift index 5b06b28..5acb5b4 100644 --- a/LoopLine/ViewModels/ProjectDetailViewModel.swift +++ b/LoopLine/ViewModels/ProjectDetailViewModel.swift @@ -22,6 +22,16 @@ struct NoteDraft { !trimmedText.isEmpty && hasValidRowNumber } + init(text: String = "", rowNumberText: String = "") { + self.text = text + self.rowNumberText = rowNumberText + } + + init(note: ProjectNote) { + text = note.text + rowNumberText = note.rowNumber.map(String.init) ?? "" + } + private var hasValidRowNumber: Bool { let trimmedRow = rowNumberText.trimmingCharacters(in: .whitespacesAndNewlines) return trimmedRow.isEmpty || rowNumber != nil @@ -157,6 +167,17 @@ final class ProjectDetailViewModel { isShowingAddNote = false } + func updateNote(_ note: ProjectNote, from draft: NoteDraft, in modelContext: ModelContext) { + note.text = draft.trimmedText + note.rowNumber = draft.rowNumber + save(modelContext) + } + + func deleteNote(_ note: ProjectNote, in modelContext: ModelContext) { + modelContext.delete(note) + save(modelContext) + } + func deleteProject(_ project: Project, in modelContext: ModelContext) { ProjectCleanupService.deleteImportedSource(for: project) modelContext.delete(project) @@ -212,6 +233,11 @@ final class AddNoteViewModel { attachesToRow = true } + init(note: ProjectNote) { + draft = NoteDraft(note: note) + attachesToRow = note.rowNumber != nil + } + func setAttachesToRow(_ isAttached: Bool) { attachesToRow = isAttached if !isAttached { diff --git a/LoopLine/ViewModels/ReadingModeViewModel.swift b/LoopLine/ViewModels/ReadingModeViewModel.swift index 0f64315..4bd5386 100644 --- a/LoopLine/ViewModels/ReadingModeViewModel.swift +++ b/LoopLine/ViewModels/ReadingModeViewModel.swift @@ -116,6 +116,17 @@ final class ReadingModeViewModel { isShowingAddNote = false } + func updateNote(_ note: ProjectNote, from draft: NoteDraft, in modelContext: ModelContext) { + note.text = draft.trimmedText + note.rowNumber = draft.rowNumber + save(modelContext) + } + + func deleteNote(_ note: ProjectNote, in modelContext: ModelContext) { + modelContext.delete(note) + save(modelContext) + } + func scrollToActiveRow(for project: Project, with proxy: ScrollViewProxy) { guard let activeRowIndex = activeRowIndex(for: project) else { return } withAnimation(.easeInOut(duration: 0.2)) { diff --git a/LoopLine/Views/ProjectDetailView.swift b/LoopLine/Views/ProjectDetailView.swift index 716eda7..91c04fd 100644 --- a/LoopLine/Views/ProjectDetailView.swift +++ b/LoopLine/Views/ProjectDetailView.swift @@ -7,6 +7,8 @@ struct ProjectDetailView: View { @Environment(\.dismiss) private var dismiss @Bindable var project: Project @State private var viewModel = ProjectDetailViewModel() + @State private var noteToEdit: ProjectNote? + @State private var notePendingDeletion: ProjectNote? var body: some View { @Bindable var viewModel = viewModel @@ -40,6 +42,27 @@ struct ProjectDetailView: View { .presentationDetents([.large]) .presentationDragIndicator(.hidden) } + .sheet(item: $noteToEdit) { note in + AddNoteView(note: note) { draft in + viewModel.updateNote(note, from: draft, in: modelContext) + noteToEdit = nil + } + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + } + .alert("Delete Note?", isPresented: deleteNoteConfirmationBinding) { + Button("Delete Note", role: .destructive) { + if let notePendingDeletion { + viewModel.deleteNote(notePendingDeletion, in: modelContext) + } + notePendingDeletion = nil + } + Button("Cancel", role: .cancel) { + notePendingDeletion = nil + } + } message: { + Text("This will permanently delete this note. This cannot be undone.") + } .alert("Delete Project?", isPresented: $viewModel.isShowingDeleteConfirmation) { Button("Delete Project", role: .destructive) { viewModel.deleteProject(project, in: modelContext) @@ -189,7 +212,11 @@ struct ProjectDetailView: View { } else { VStack(spacing: 0) { ForEach(project.notes) { note in - NoteRow(note: note) + NoteRow( + note: note, + editAction: { noteToEdit = note }, + deleteAction: { notePendingDeletion = note } + ) if note.id != project.notes.last?.id { Divider() .padding(.leading, 46) @@ -207,6 +234,17 @@ struct ProjectDetailView: View { } } + private var deleteNoteConfirmationBinding: Binding { + Binding( + get: { notePendingDeletion != nil }, + set: { isPresented in + if !isPresented { + notePendingDeletion = nil + } + } + ) + } + private var trackingSection: some View { VStack(alignment: .leading, spacing: 14) { LoopLineSectionHeader(title: "Tracking") @@ -429,10 +467,21 @@ struct AddNoteView: View { @State private var viewModel: AddNoteViewModel let onSave: (NoteDraft) -> Void + private let navigationTitle: LocalizedStringResource + private let primaryButtonTitle: LocalizedStringResource init(currentRow: Int, onSave: @escaping (NoteDraft) -> Void) { _viewModel = State(initialValue: AddNoteViewModel(currentRow: currentRow)) self.onSave = onSave + navigationTitle = "Add Note" + primaryButtonTitle = "Save Note" + } + + init(note: ProjectNote, onSave: @escaping (NoteDraft) -> Void) { + _viewModel = State(initialValue: AddNoteViewModel(note: note)) + self.onSave = onSave + navigationTitle = "Edit Note" + primaryButtonTitle = "Save Changes" } var body: some View { @@ -486,7 +535,7 @@ struct AddNoteView: View { .padding(.bottom, 96) } .background(LoopLineTheme.appBackground.ignoresSafeArea()) - .navigationTitle("Add Note") + .navigationTitle(navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -503,7 +552,7 @@ struct AddNoteView: View { } } .safeAreaInset(edge: .bottom) { - Button("Save Note") { + Button(primaryButtonTitle) { onSave(viewModel.draft) } .buttonStyle(LoopLinePrimaryButtonStyle()) @@ -599,6 +648,8 @@ struct AddNoteView: View { private struct NoteRow: View { let note: ProjectNote + let editAction: () -> Void + let deleteAction: () -> Void var body: some View { HStack(alignment: .top, spacing: 14) { @@ -614,6 +665,15 @@ private struct NoteRow: View { .fixedSize(horizontal: false, vertical: true) Spacer() + + Menu { + Button("Edit", systemImage: "pencil", action: editAction) + Button("Delete", systemImage: "trash", role: .destructive, action: deleteAction) + } label: { + Image(systemName: "ellipsis") + .frame(width: 44, height: 44) + } + .accessibilityLabel("Note actions") } .padding(.vertical, 14) } diff --git a/LoopLine/Views/ReadingModeView.swift b/LoopLine/Views/ReadingModeView.swift index 6a3ae06..e9dc309 100644 --- a/LoopLine/Views/ReadingModeView.swift +++ b/LoopLine/Views/ReadingModeView.swift @@ -7,6 +7,8 @@ struct ReadingModeView: View { @Bindable var project: Project @Query private var settings: [AppSettings] @State private var viewModel = ReadingModeViewModel() + @State private var noteToEdit: ProjectNote? + @State private var notePendingDeletion: ProjectNote? private var appSettings: AppSettings { settings.first ?? AppSettings() @@ -53,6 +55,27 @@ struct ReadingModeView: View { .presentationDetents([.large]) .presentationDragIndicator(.hidden) } + .sheet(item: $noteToEdit) { note in + AddNoteView(note: note) { draft in + viewModel.updateNote(note, from: draft, in: modelContext) + noteToEdit = nil + } + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + } + .alert("Delete Note?", isPresented: deleteNoteConfirmationBinding) { + Button("Delete Note", role: .destructive) { + if let notePendingDeletion { + viewModel.deleteNote(notePendingDeletion, in: modelContext) + } + notePendingDeletion = nil + } + Button("Cancel", role: .cancel) { + notePendingDeletion = nil + } + } message: { + Text("This will permanently delete this note. This cannot be undone.") + } .alert("Reset Counters?", isPresented: $viewModel.isShowingResetConfirmation) { Button("Reset", role: .destructive) { viewModel.resetCounters(for: project, in: modelContext) @@ -184,33 +207,64 @@ struct ReadingModeView: View { } private var reminderStrip: some View { - Button { - viewModel.isShowingAddNote = true - } label: { - HStack(spacing: 10) { - Image(systemName: "flag.fill") - .foregroundStyle(LoopLineTheme.readingGuide) - - Text(viewModel.reminderText(for: project)) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(primaryText) - .lineLimit(1) - - Spacer() - - Text("+ Add") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(LoopLineTheme.accent) + let notes = viewModel.currentRowNotes(for: project) + + return HStack(spacing: 10) { + Image(systemName: "flag.fill") + .foregroundStyle(LoopLineTheme.readingGuide) + + Text(viewModel.reminderText(for: project)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(primaryText) + .lineLimit(1) + + Spacer() + + if !notes.isEmpty { + Menu { + ForEach(notes) { note in + Menu { + Button("Edit", systemImage: "pencil") { + noteToEdit = note + } + Button("Delete", systemImage: "trash", role: .destructive) { + notePendingDeletion = note + } + } label: { + Text(note.text) + } + } + } label: { + Image(systemName: "ellipsis") + .frame(width: 32, height: 44) + } + .accessibilityLabel("Note actions") } - .padding(.horizontal, 16) - .frame(height: 54) - .background(stripBackground, in: RoundedRectangle(cornerRadius: LoopLineTheme.compactCornerRadius, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: LoopLineTheme.compactCornerRadius, style: .continuous) - .stroke(panelStroke, lineWidth: 1) + + Button("+ Add") { + viewModel.isShowingAddNote = true } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(LoopLineTheme.accent) } - .buttonStyle(.plain) + .padding(.horizontal, 16) + .frame(height: 54) + .background(stripBackground, in: RoundedRectangle(cornerRadius: LoopLineTheme.compactCornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: LoopLineTheme.compactCornerRadius, style: .continuous) + .stroke(panelStroke, lineWidth: 1) + } + } + + private var deleteNoteConfirmationBinding: Binding { + Binding( + get: { notePendingDeletion != nil }, + set: { isPresented in + if !isPresented { + notePendingDeletion = nil + } + } + ) } private var readingBackground: Color { diff --git a/LoopLineTests/ProjectPersistenceTests.swift b/LoopLineTests/ProjectPersistenceTests.swift index 2c40c14..9c38aed 100644 --- a/LoopLineTests/ProjectPersistenceTests.swift +++ b/LoopLineTests/ProjectPersistenceTests.swift @@ -162,6 +162,37 @@ final class ProjectPersistenceTests: XCTestCase { XCTAssertEqual(savedProject.repeatCurrent, 3) } + @MainActor + func testEditingNotePersistsTextAndRow() throws { + let container = try makeContainer() + let context = ModelContext(container) + let note = ProjectNote(text: "Original", rowNumber: 2) + let project = Project(name: "Scarf", sourceType: .text, notes: [note]) + context.insert(project) + try context.save() + + let draft = NoteDraft(text: " Updated note ", rowNumberText: "5") + ProjectDetailViewModel().updateNote(note, from: draft, in: context) + + let savedNote = try XCTUnwrap(try context.fetch(FetchDescriptor()).first) + XCTAssertEqual(savedNote.text, "Updated note") + XCTAssertEqual(savedNote.rowNumber, 5) + } + + @MainActor + func testDeletingNoteRemovesItFromPersistence() throws { + let container = try makeContainer() + let context = ModelContext(container) + let note = ProjectNote(text: "Remove me") + let project = Project(name: "Scarf", sourceType: .text, notes: [note]) + context.insert(project) + try context.save() + + ProjectDetailViewModel().deleteNote(note, in: context) + + XCTAssertTrue(try context.fetch(FetchDescriptor()).isEmpty) + } + private func makeContainer() throws -> ModelContainer { try ModelContainer( for: Project.self, ProjectNote.self, diff --git a/README.md b/README.md index d1811cb..4eb4e2b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # LoopLine -LoopLine is a native iOS and iPadOS knitting companion app built with SwiftUI. +LoopLine is a native iOS and iPadOS knitting companion app built with Swift/SwiftUI. It helps knitters keep pattern projects organized, track their progress, and read instructions comfortably while knitting. ## Features