Skip to content
Merged
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
12 changes: 1 addition & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
84 changes: 83 additions & 1 deletion Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1547,4 +1629,4 @@
}
},
"version" : "1.3"
}
}
26 changes: 26 additions & 0 deletions LoopLine/ViewModels/ProjectDetailViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions LoopLine/ViewModels/ReadingModeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
66 changes: 63 additions & 3 deletions LoopLine/Views/ProjectDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -207,6 +234,17 @@ struct ProjectDetailView: View {
}
}

private var deleteNoteConfirmationBinding: Binding<Bool> {
Binding(
get: { notePendingDeletion != nil },
set: { isPresented in
if !isPresented {
notePendingDeletion = nil
}
}
)
}

private var trackingSection: some View {
VStack(alignment: .leading, spacing: 14) {
LoopLineSectionHeader(title: "Tracking")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand All @@ -503,7 +552,7 @@ struct AddNoteView: View {
}
}
.safeAreaInset(edge: .bottom) {
Button("Save Note") {
Button(primaryButtonTitle) {
onSave(viewModel.draft)
}
.buttonStyle(LoopLinePrimaryButtonStyle())
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down
Loading
Loading