From d2435737f970c6047276b7876a1ccc5ee4877109 Mon Sep 17 00:00:00 2001 From: Dominik Moreira Date: Sun, 18 Oct 2026 18:08:18 +0100 Subject: [PATCH 1/4] Track last selected Metrics and PDF Index --- LoopLine/Models/Project.swift | 3 + LoopLine/ViewModels/PDFReadingViewModel.swift | 39 +++++++ LoopLine/Views/PDFReadingView.swift | 108 ++++++++++++++++-- LoopLineTests/ProjectPersistenceTests.swift | 37 ++++++ 4 files changed, 180 insertions(+), 7 deletions(-) diff --git a/LoopLine/Models/Project.swift b/LoopLine/Models/Project.swift index fa59f9d..22022c9 100644 --- a/LoopLine/Models/Project.swift +++ b/LoopLine/Models/Project.swift @@ -12,6 +12,9 @@ final class Project { var repeatCurrent: Int var currentStitch: Int = 0 var repeatTotal: Int? + var selectedTrackingMetricRawValue: String = ReadingTrackingMetric.row.rawValue + var lastPDFPageIndex: Int = 0 + var lastPDFPageVerticalPosition: Double = 0 var rows: [String] var sourceText: String? var sourceFilePath: String? diff --git a/LoopLine/ViewModels/PDFReadingViewModel.swift b/LoopLine/ViewModels/PDFReadingViewModel.swift index 7f5e388..b9231d1 100644 --- a/LoopLine/ViewModels/PDFReadingViewModel.swift +++ b/LoopLine/ViewModels/PDFReadingViewModel.swift @@ -13,6 +13,40 @@ final class PDFReadingViewModel { ImportedPDFStorage.fileURL(for: project) } + func restoreSelectedMetric(for project: Project) { + selectedMetric = ReadingTrackingMetric(rawValue: project.selectedTrackingMetricRawValue) ?? .row + } + + func selectMetric(_ metric: ReadingTrackingMetric, for project: Project, in modelContext: ModelContext) { + selectedMetric = metric + guard project.selectedTrackingMetricRawValue != metric.rawValue else { return } + + project.selectedTrackingMetricRawValue = metric.rawValue + save(modelContext) + } + + func restoreReadingPosition(for project: Project) -> PDFReadingPosition { + PDFReadingPosition( + pageIndex: max(project.lastPDFPageIndex, 0), + verticalPosition: min(max(project.lastPDFPageVerticalPosition, 0), 1) + ) + } + + func saveReadingPosition( + _ position: PDFReadingPosition, + for project: Project, + in modelContext: ModelContext + ) { + guard project.lastPDFPageIndex != position.pageIndex || + project.lastPDFPageVerticalPosition != position.verticalPosition else { + return + } + + project.lastPDFPageIndex = position.pageIndex + project.lastPDFPageVerticalPosition = position.verticalPosition + save(modelContext) + } + func selectedMetricValue(for project: Project) -> Int { switch selectedMetric { case .row: @@ -81,6 +115,11 @@ final class PDFReadingViewModel { } } +struct PDFReadingPosition: Equatable { + let pageIndex: Int + let verticalPosition: Double +} + struct PDFMarkupError: Identifiable { let id = UUID() let message: String diff --git a/LoopLine/Views/PDFReadingView.swift b/LoopLine/Views/PDFReadingView.swift index fedf14b..64f158e 100644 --- a/LoopLine/Views/PDFReadingView.swift +++ b/LoopLine/Views/PDFReadingView.swift @@ -28,7 +28,11 @@ struct PDFReadingView: View { PDFKitView( url: pdfURL, isMarkupActive: viewModel.isMarkupActive, - markupError: $viewModel.markupError + readingPosition: viewModel.restoreReadingPosition(for: project), + markupError: $viewModel.markupError, + onReadingPositionChanged: { position in + viewModel.saveReadingPosition(position, for: project, in: modelContext) + } ) .background(LoopLineTheme.surface, in: RoundedRectangle(cornerRadius: LoopLineTheme.cornerRadius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: LoopLineTheme.cornerRadius, style: .continuous)) @@ -87,6 +91,7 @@ struct PDFReadingView: View { ) } .onAppear { + viewModel.restoreSelectedMetric(for: project) viewModel.normalizeTrackingValues(for: project, in: modelContext) } } @@ -135,7 +140,7 @@ struct PDFReadingView: View { Menu { ForEach(ReadingTrackingMetric.allCases) { metric in Button(metric.title) { - viewModel.selectedMetric = metric + viewModel.selectMetric(metric, for: project, in: modelContext) } } } label: { @@ -249,10 +254,17 @@ private final class PDFMarkupPDFView: PDFView { private struct PDFKitView: UIViewRepresentable { let url: URL let isMarkupActive: Bool + let readingPosition: PDFReadingPosition @Binding var markupError: PDFMarkupError? + let onReadingPositionChanged: (PDFReadingPosition) -> Void func makeCoordinator() -> Coordinator { - Coordinator(url: url, markupError: $markupError) + Coordinator( + url: url, + markupError: $markupError, + readingPosition: readingPosition, + onReadingPositionChanged: onReadingPositionChanged + ) } func makeUIView(context: Context) -> PDFView { @@ -263,7 +275,12 @@ private struct PDFKitView: UIViewRepresentable { pdfView.backgroundColor = .clear pdfView.document = PDFDocument(url: url) - context.coordinator.configure(pdfView: pdfView, isMarkupActive: isMarkupActive) + context.coordinator.configure( + pdfView: pdfView, + isMarkupActive: isMarkupActive, + readingPosition: readingPosition, + onReadingPositionChanged: onReadingPositionChanged + ) return pdfView } @@ -273,7 +290,12 @@ private struct PDFKitView: UIViewRepresentable { pdfView.document = PDFDocument(url: url) } - context.coordinator.configure(pdfView: pdfView, isMarkupActive: isMarkupActive) + context.coordinator.configure( + pdfView: pdfView, + isMarkupActive: isMarkupActive, + readingPosition: readingPosition, + onReadingPositionChanged: onReadingPositionChanged + ) } static func dismantleUIView(_ pdfView: PDFView, coordinator: Coordinator) { @@ -285,6 +307,9 @@ private struct PDFKitView: UIViewRepresentable { private var isMarkupActive = false private var markupError: Binding + private var readingPosition: PDFReadingPosition + private var onReadingPositionChanged: (PDFReadingPosition) -> Void + private var hasRestoredReadingPosition = false private let toolPicker = PKToolPicker() private let defaultMarkupTool = PKInkingTool(.marker, color: UIColor.systemYellow.withAlphaComponent(0.38), width: 14) private var currentTool: any PKTool @@ -299,9 +324,16 @@ private struct PDFKitView: UIViewRepresentable { private var annotationsByStrokeID = [UUID: PDFMarkupAnnotation]() private var hasLoadedStoredMarkups = false - init(url: URL, markupError: Binding) { + init( + url: URL, + markupError: Binding, + readingPosition: PDFReadingPosition, + onReadingPositionChanged: @escaping (PDFReadingPosition) -> Void + ) { self.url = url self.markupError = markupError + self.readingPosition = readingPosition + self.onReadingPositionChanged = onReadingPositionChanged self.currentTool = defaultMarkupTool self.markupStorage = PDFMarkupStorage(pdfURL: url) super.init() @@ -330,6 +362,7 @@ private struct PDFKitView: UIViewRepresentable { } func teardown() { + saveCurrentReadingPosition() finishActiveMarkup() saveStoredMarkups() saveWorkItem?.cancel() @@ -343,11 +376,13 @@ private struct PDFKitView: UIViewRepresentable { } @objc private func saveBeforeAppResignsActive() { + saveCurrentReadingPosition() finishActiveMarkup() saveStoredMarkups() } func reset(for url: URL) { + saveCurrentReadingPosition() finishActiveMarkup() saveStoredMarkups() saveWorkItem?.cancel() @@ -357,12 +392,71 @@ private struct PDFKitView: UIViewRepresentable { markupStorage = PDFMarkupStorage(pdfURL: url) storedStrokesByPage.removeAll() hasLoadedStoredMarkups = false + hasRestoredReadingPosition = false + } + + private func restoreReadingPositionIfNeeded(in pdfView: PDFView) { + guard !hasRestoredReadingPosition else { return } + hasRestoredReadingPosition = true + + DispatchQueue.main.async { [weak self, weak pdfView] in + guard let self, + let pdfView, + let currentPDFView = self.pdfView, + currentPDFView === pdfView, + let document = pdfView.document, + readingPosition.pageIndex < document.pageCount, + let page = document.page(at: readingPosition.pageIndex) else { + return + } + + let bounds = page.bounds(for: .cropBox) + let destination = PDFDestination( + page: page, + at: CGPoint( + x: bounds.midX, + y: bounds.minY + (bounds.height * readingPosition.verticalPosition) + ) + ) + pdfView.go(to: destination) + } + } + + private func saveCurrentReadingPosition() { + guard let pdfView, + let document = pdfView.document else { + return + } + + let midpoint = CGPoint(x: pdfView.bounds.midX, y: pdfView.bounds.midY) + guard let page = pdfView.page(for: midpoint, nearest: true) else { + return + } + + let pageIndex = document.index(for: page) + let bounds = page.bounds(for: .cropBox) + guard pageIndex >= 0, bounds.height > 0 else { return } + + let pagePoint = pdfView.convert(midpoint, to: page) + let verticalPosition = min(max((pagePoint.y - bounds.minY) / bounds.height, 0), 1) + onReadingPositionChanged(PDFReadingPosition( + pageIndex: pageIndex, + verticalPosition: verticalPosition + )) } - func configure(pdfView: PDFView, isMarkupActive: Bool) { + func configure( + pdfView: PDFView, + isMarkupActive: Bool, + readingPosition: PDFReadingPosition, + onReadingPositionChanged: @escaping (PDFReadingPosition) -> Void + ) { self.pdfView = pdfView + self.readingPosition = readingPosition + self.onReadingPositionChanged = onReadingPositionChanged installMarkupGestureIfNeeded(on: pdfView) loadStoredMarkupsIfNeeded(in: pdfView) + restoreReadingPositionIfNeeded(in: pdfView) pdfView.isInMarkupMode = isMarkupActive (pdfView as? PDFMarkupPDFView)?.isMarkupModeActive = isMarkupActive markupGesture?.isEnabled = isMarkupActive diff --git a/LoopLineTests/ProjectPersistenceTests.swift b/LoopLineTests/ProjectPersistenceTests.swift index 62abc66..e8565b2 100644 --- a/LoopLineTests/ProjectPersistenceTests.swift +++ b/LoopLineTests/ProjectPersistenceTests.swift @@ -6,6 +6,7 @@ // import SwiftData +import Testing import UIKit import XCTest @testable import LoopLine @@ -235,3 +236,39 @@ final class ProjectPersistenceTests: XCTestCase { case saveFailed } } + +@Suite("PDF Reading State Persistence") +struct PDFReadingStatePersistenceTests { + @Test @MainActor + func persistsReadingPositionAndSelectedMetric() throws { + let container = try ModelContainer( + for: Project.self, ProjectNote.self, + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + let context = ModelContext(container) + let project = Project(name: "Cable Chart", sourceType: .pdf) + project.lastPDFPageIndex = 4 + project.lastPDFPageVerticalPosition = 0.375 + project.selectedTrackingMetricRawValue = ReadingTrackingMetric.stitches.rawValue + context.insert(project) + try context.save() + + let freshContext = ModelContext(container) + let savedProject = try #require(try freshContext.fetch(FetchDescriptor()).first) + + #expect(savedProject.lastPDFPageIndex == 4) + #expect(savedProject.lastPDFPageVerticalPosition == 0.375) + #expect(savedProject.selectedTrackingMetricRawValue == ReadingTrackingMetric.stitches.rawValue) + } + + @Test @MainActor + func fallsBackToRowForAnUnknownSavedMetric() { + let project = Project(name: "Cable Chart", sourceType: .pdf) + project.selectedTrackingMetricRawValue = "unknown" + let viewModel = PDFReadingViewModel() + + viewModel.restoreSelectedMetric(for: project) + + #expect(viewModel.selectedMetric == .row) + } +} From 994c6bfebe6eaba71de1b7c085c9f89c233f3da4 Mon Sep 17 00:00:00 2001 From: Dominik Moreira Date: Sun, 18 Oct 2026 19:49:48 +0100 Subject: [PATCH 2/4] Add last action note --- Localizable.xcstrings | 153 ++++++++++++++++++ LoopLine/Models/Project.swift | 4 + LoopLine/Models/ReadingTrackingMetric.swift | 98 +++++++++++ LoopLine/ViewModels/PDFReadingViewModel.swift | 10 ++ .../ViewModels/ProjectDetailViewModel.swift | 6 + .../ViewModels/ReadingModeViewModel.swift | 13 +- LoopLine/Views/LoopLineUIComponents.swift | 22 +++ LoopLine/Views/PDFReadingView.swift | 10 +- LoopLine/Views/ReadingModeView.swift | 4 + LoopLineTests/ProjectPersistenceTests.swift | 125 ++++++++++++++ 10 files changed, 443 insertions(+), 2 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 57b34e0..ed548d4 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -660,6 +660,159 @@ } } }, + "Last: Repeat decreased to %@ · %@" : { + "comment" : "Reading mode status line for the last repeat counter decrease. The first placeholder is the new value and the second is the local time or date and time.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zuletzt: Wiederholung auf %1$@ verringert · %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last: Repeat decreased to %1$@ · %2$@" + } + } + } + }, + "Last: Repeat increased to %@ · %@" : { + "comment" : "Reading mode status line for the last repeat counter increase. The first placeholder is the new value and the second is the local time or date and time.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zuletzt: Wiederholung auf %1$@ erhöht · %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last: Repeat increased to %1$@ · %2$@" + } + } + } + }, + "Last: Repeat set to %@ · %@" : { + "comment" : "Reading mode status line for a directly set repeat counter. The first placeholder is the new value and the second is the local time or date and time.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zuletzt: Wiederholung auf %1$@ gesetzt · %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last: Repeat set to %1$@ · %2$@" + } + } + } + }, + "Last: Row decreased to %@ · %@" : { + "comment" : "Reading mode status line for the last row counter decrease. The first placeholder is the new value and the second is the local time or date and time.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zuletzt: Reihe auf %1$@ verringert · %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last: Row decreased to %1$@ · %2$@" + } + } + } + }, + "Last: Row increased to %@ · %@" : { + "comment" : "Reading mode status line for the last row counter increase. The first placeholder is the new value and the second is the local time or date and time.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zuletzt: Reihe auf %1$@ erhöht · %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last: Row increased to %1$@ · %2$@" + } + } + } + }, + "Last: Row set to %@ · %@" : { + "comment" : "Reading mode status line for a directly set row counter. The first placeholder is the new value and the second is the local time or date and time.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zuletzt: Reihe auf %1$@ gesetzt · %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last: Row set to %1$@ · %2$@" + } + } + } + }, + "Last: Stitches decreased to %@ · %@" : { + "comment" : "Reading mode status line for the last stitch counter decrease. The first placeholder is the new value and the second is the local time or date and time.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zuletzt: Maschen auf %1$@ verringert · %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last: Stitches decreased to %1$@ · %2$@" + } + } + } + }, + "Last: Stitches increased to %@ · %@" : { + "comment" : "Reading mode status line for the last stitch counter increase. The first placeholder is the new value and the second is the local time or date and time.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zuletzt: Maschen auf %1$@ erhöht · %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last: Stitches increased to %1$@ · %2$@" + } + } + } + }, + "Last: Stitches set to %@ · %@" : { + "comment" : "Reading mode status line for a directly set stitch counter. The first placeholder is the new value and the second is the local time or date and time.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "machine_translated", + "value" : "Zuletzt: Maschen auf %1$@ gesetzt · %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Last: Stitches set to %1$@ · %2$@" + } + } + } + }, "LoopLine" : { "comment" : "The name of the app.", "isCommentAutoGenerated" : true, diff --git a/LoopLine/Models/Project.swift b/LoopLine/Models/Project.swift index 22022c9..a858eb1 100644 --- a/LoopLine/Models/Project.swift +++ b/LoopLine/Models/Project.swift @@ -13,6 +13,10 @@ final class Project { var currentStitch: Int = 0 var repeatTotal: Int? var selectedTrackingMetricRawValue: String = ReadingTrackingMetric.row.rawValue + var lastCounterMetricRawValue: String? + var lastCounterValue: Int? + var lastCounterChangeDirectionRawValue: String? + var lastCounterChangedAt: Date? var lastPDFPageIndex: Int = 0 var lastPDFPageVerticalPosition: Double = 0 var rows: [String] diff --git a/LoopLine/Models/ReadingTrackingMetric.swift b/LoopLine/Models/ReadingTrackingMetric.swift index 6f03a12..7b9f12d 100644 --- a/LoopLine/Models/ReadingTrackingMetric.swift +++ b/LoopLine/Models/ReadingTrackingMetric.swift @@ -18,3 +18,101 @@ enum ReadingTrackingMetric: String, CaseIterable, Identifiable { } } } + +enum CounterChangeDirection: String { + case increased + case decreased + case set +} + +struct LastCounterChange: Equatable { + let metric: ReadingTrackingMetric + let value: Int + let direction: CounterChangeDirection + let timestamp: Date +} + +extension Project { + var lastCounterChange: LastCounterChange? { + guard let metricRawValue = lastCounterMetricRawValue, + let metric = ReadingTrackingMetric(rawValue: metricRawValue), + let value = lastCounterValue, + let directionRawValue = lastCounterChangeDirectionRawValue, + let direction = CounterChangeDirection(rawValue: directionRawValue), + let timestamp = lastCounterChangedAt else { + return nil + } + + return LastCounterChange( + metric: metric, + value: value, + direction: direction, + timestamp: timestamp + ) + } + + func recordLastCounterChange( + metric: ReadingTrackingMetric, + value: Int, + direction: CounterChangeDirection, + timestamp: Date = .now + ) { + lastCounterMetricRawValue = metric.rawValue + lastCounterValue = value + lastCounterChangeDirectionRawValue = direction.rawValue + lastCounterChangedAt = timestamp + } + + func clearLastCounterChange() { + lastCounterMetricRawValue = nil + lastCounterValue = nil + lastCounterChangeDirectionRawValue = nil + lastCounterChangedAt = nil + } +} + +enum LastCounterChangeFormatter { + static func timestampText( + for date: Date, + now: Date = .now, + calendar: Calendar = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent + ) -> String { + if calendar.isDate(date, inSameDayAs: now) { + return date.formatted(.dateTime.hour().minute().locale(locale)) + } + + return date.formatted(.dateTime.year().month(.abbreviated).day().hour().minute().locale(locale)) + } + + static func statusText( + for change: LastCounterChange, + now: Date = .now, + calendar: Calendar = .autoupdatingCurrent, + locale: Locale = .autoupdatingCurrent + ) -> String { + let value = change.value.formatted(.number.locale(locale)) + let timestamp = timestampText(for: change.timestamp, now: now, calendar: calendar, locale: locale) + + switch (change.metric, change.direction) { + case (.row, .increased): + return String(localized: "Last: Row increased to \(value) · \(timestamp)", comment: "Reading mode status line for the last row counter increase. The first placeholder is the new value and the second is the local time or date and time.") + case (.row, .decreased): + return String(localized: "Last: Row decreased to \(value) · \(timestamp)", comment: "Reading mode status line for the last row counter decrease. The first placeholder is the new value and the second is the local time or date and time.") + case (.row, .set): + return String(localized: "Last: Row set to \(value) · \(timestamp)", comment: "Reading mode status line for a directly set row counter. The first placeholder is the new value and the second is the local time or date and time.") + case (.stitches, .increased): + return String(localized: "Last: Stitches increased to \(value) · \(timestamp)", comment: "Reading mode status line for the last stitch counter increase. The first placeholder is the new value and the second is the local time or date and time.") + case (.stitches, .decreased): + return String(localized: "Last: Stitches decreased to \(value) · \(timestamp)", comment: "Reading mode status line for the last stitch counter decrease. The first placeholder is the new value and the second is the local time or date and time.") + case (.stitches, .set): + return String(localized: "Last: Stitches set to \(value) · \(timestamp)", comment: "Reading mode status line for a directly set stitch counter. The first placeholder is the new value and the second is the local time or date and time.") + case (.repeatCount, .increased): + return String(localized: "Last: Repeat increased to \(value) · \(timestamp)", comment: "Reading mode status line for the last repeat counter increase. The first placeholder is the new value and the second is the local time or date and time.") + case (.repeatCount, .decreased): + return String(localized: "Last: Repeat decreased to \(value) · \(timestamp)", comment: "Reading mode status line for the last repeat counter decrease. The first placeholder is the new value and the second is the local time or date and time.") + case (.repeatCount, .set): + return String(localized: "Last: Repeat set to \(value) · \(timestamp)", comment: "Reading mode status line for a directly set repeat counter. The first placeholder is the new value and the second is the local time or date and time.") + } + } +} diff --git a/LoopLine/ViewModels/PDFReadingViewModel.swift b/LoopLine/ViewModels/PDFReadingViewModel.swift index b9231d1..d9ee739 100644 --- a/LoopLine/ViewModels/PDFReadingViewModel.swift +++ b/LoopLine/ViewModels/PDFReadingViewModel.swift @@ -72,6 +72,11 @@ final class PDFReadingViewModel { project.repeatCurrent += 1 } + project.recordLastCounterChange( + metric: selectedMetric, + value: selectedMetricValue(for: project), + direction: .increased + ) save(modelContext) } @@ -87,6 +92,11 @@ final class PDFReadingViewModel { project.repeatCurrent = max(project.repeatCurrent - 1, 0) } + project.recordLastCounterChange( + metric: selectedMetric, + value: selectedMetricValue(for: project), + direction: .decreased + ) save(modelContext) } diff --git a/LoopLine/ViewModels/ProjectDetailViewModel.swift b/LoopLine/ViewModels/ProjectDetailViewModel.swift index 5acb5b4..f49c0a8 100644 --- a/LoopLine/ViewModels/ProjectDetailViewModel.swift +++ b/LoopLine/ViewModels/ProjectDetailViewModel.swift @@ -123,35 +123,41 @@ final class ProjectDetailViewModel { func incrementRow(for project: Project, in modelContext: ModelContext) { guard canIncreaseRow(for: project) else { return } project.currentRow += 1 + project.recordLastCounterChange(metric: .row, value: project.currentRow, direction: .increased) save(modelContext) } func decrementRow(for project: Project, in modelContext: ModelContext) { guard project.currentRow > 0 else { return } project.currentRow -= 1 + project.recordLastCounterChange(metric: .row, value: project.currentRow, direction: .decreased) save(modelContext) } func incrementRepeat(for project: Project, in modelContext: ModelContext) { guard canIncreaseRepeat(for: project) else { return } project.repeatCurrent += 1 + project.recordLastCounterChange(metric: .repeatCount, value: project.repeatCurrent, direction: .increased) save(modelContext) } func decrementRepeat(for project: Project, in modelContext: ModelContext) { guard project.repeatCurrent > 0 else { return } project.repeatCurrent -= 1 + project.recordLastCounterChange(metric: .repeatCount, value: project.repeatCurrent, direction: .decreased) save(modelContext) } func incrementStitch(for project: Project, in modelContext: ModelContext) { project.currentStitch += 1 + project.recordLastCounterChange(metric: .stitches, value: project.currentStitch, direction: .increased) save(modelContext) } func decrementStitch(for project: Project, in modelContext: ModelContext) { guard project.currentStitch > 0 else { return } project.currentStitch -= 1 + project.recordLastCounterChange(metric: .stitches, value: project.currentStitch, direction: .decreased) save(modelContext) } diff --git a/LoopLine/ViewModels/ReadingModeViewModel.swift b/LoopLine/ViewModels/ReadingModeViewModel.swift index 4bd5386..17e6693 100644 --- a/LoopLine/ViewModels/ReadingModeViewModel.swift +++ b/LoopLine/ViewModels/ReadingModeViewModel.swift @@ -57,50 +57,61 @@ final class ReadingModeViewModel { } func selectRow(at index: Int, in project: Project, modelContext: ModelContext) { - guard project.rows.indices.contains(index) else { return } + guard project.rows.indices.contains(index), project.currentRow != index else { return } project.currentRow = index + project.recordLastCounterChange(metric: .row, value: index, direction: .set) save(modelContext) } func incrementRow(for project: Project, in modelContext: ModelContext) { guard canIncreaseRow(for: project) else { return } project.currentRow += 1 + project.recordLastCounterChange(metric: .row, value: project.currentRow, direction: .increased) save(modelContext) } func decrementRow(for project: Project, in modelContext: ModelContext) { guard project.currentRow > 0 else { return } project.currentRow -= 1 + project.recordLastCounterChange(metric: .row, value: project.currentRow, direction: .decreased) save(modelContext) } func incrementRepeat(for project: Project, in modelContext: ModelContext) { guard canIncreaseRepeat(for: project) else { return } project.repeatCurrent += 1 + project.recordLastCounterChange(metric: .repeatCount, value: project.repeatCurrent, direction: .increased) save(modelContext) } func decrementRepeat(for project: Project, in modelContext: ModelContext) { guard project.repeatCurrent > 0 else { return } project.repeatCurrent -= 1 + project.recordLastCounterChange(metric: .repeatCount, value: project.repeatCurrent, direction: .decreased) save(modelContext) } func incrementStitch(for project: Project, in modelContext: ModelContext) { project.currentStitch += 1 + project.recordLastCounterChange(metric: .stitches, value: project.currentStitch, direction: .increased) save(modelContext) } func decrementStitch(for project: Project, in modelContext: ModelContext) { guard project.currentStitch > 0 else { return } project.currentStitch -= 1 + project.recordLastCounterChange(metric: .stitches, value: project.currentStitch, direction: .decreased) save(modelContext) } func resetCounters(for project: Project, in modelContext: ModelContext) { + let didChange = project.currentRow != 0 || project.repeatCurrent != 0 || project.currentStitch != 0 project.currentRow = 0 project.repeatCurrent = 0 project.currentStitch = 0 + if didChange { + project.clearLastCounterChange() + } save(modelContext) } diff --git a/LoopLine/Views/LoopLineUIComponents.swift b/LoopLine/Views/LoopLineUIComponents.swift index f448860..87bc52f 100644 --- a/LoopLine/Views/LoopLineUIComponents.swift +++ b/LoopLine/Views/LoopLineUIComponents.swift @@ -332,3 +332,25 @@ struct LoopLineFieldLabel: View { .foregroundStyle(LoopLineTheme.secondaryText) } } + +struct LastCounterChangeLabel: View { + let change: LastCounterChange + + @Environment(\.calendar) private var calendar + @Environment(\.locale) private var locale + + var body: some View { + let statusText = LastCounterChangeFormatter.statusText( + for: change, + calendar: calendar, + locale: locale + ) + + Text(statusText) + .font(.footnote) + .foregroundStyle(LoopLineTheme.readingSecondaryText) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel(statusText) + } +} diff --git a/LoopLine/Views/PDFReadingView.swift b/LoopLine/Views/PDFReadingView.swift index 64f158e..435af03 100644 --- a/LoopLine/Views/PDFReadingView.swift +++ b/LoopLine/Views/PDFReadingView.swift @@ -97,7 +97,7 @@ struct PDFReadingView: View { } private var trackingControls: some View { - Group { + VStack(alignment: .leading, spacing: 8) { if usesLargeControls { VStack(alignment: .leading, spacing: 12) { metricPicker @@ -111,6 +111,10 @@ struct PDFReadingView: View { increaseButton } .frame(maxWidth: .infinity) + + if let change = project.lastCounterChange { + LastCounterChangeLabel(change: change) + } } } else { HStack(spacing: 16) { @@ -125,6 +129,10 @@ struct PDFReadingView: View { increaseButton } + + if let change = project.lastCounterChange { + LastCounterChangeLabel(change: change) + } } } .padding(.horizontal, 12) diff --git a/LoopLine/Views/ReadingModeView.swift b/LoopLine/Views/ReadingModeView.swift index c932915..221da16 100644 --- a/LoopLine/Views/ReadingModeView.swift +++ b/LoopLine/Views/ReadingModeView.swift @@ -198,6 +198,10 @@ struct ReadingModeView: View { increaseAction: { viewModel.incrementStitch(for: project, in: modelContext) } ) + if let change = project.lastCounterChange { + LastCounterChangeLabel(change: change) + } + reminderStrip } .padding(.horizontal, 18) diff --git a/LoopLineTests/ProjectPersistenceTests.swift b/LoopLineTests/ProjectPersistenceTests.swift index e8565b2..595643e 100644 --- a/LoopLineTests/ProjectPersistenceTests.swift +++ b/LoopLineTests/ProjectPersistenceTests.swift @@ -225,6 +225,131 @@ final class ProjectPersistenceTests: XCTestCase { XCTAssertTrue(try context.fetch(FetchDescriptor()).isEmpty) } + @MainActor + func testCounterChangesRecordMetricValueDirectionAndTimestamp() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = Project(name: "Scarf", sourceType: .pdf) + let viewModel = PDFReadingViewModel() + + viewModel.incrementSelectedMetric(for: project, in: context) + + let change = try XCTUnwrap(project.lastCounterChange) + XCTAssertEqual(change.metric, .row) + XCTAssertEqual(change.value, 1) + XCTAssertEqual(change.direction, .increased) + XCTAssertNotNil(project.lastCounterChangedAt) + } + + @MainActor + func testNewCounterChangeReplacesThePreviousChange() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = Project(name: "Scarf", sourceType: .pdf) + let viewModel = PDFReadingViewModel() + + viewModel.incrementSelectedMetric(for: project, in: context) + viewModel.selectMetric(.stitches, for: project, in: context) + viewModel.incrementSelectedMetric(for: project, in: context) + + let change = try XCTUnwrap(project.lastCounterChange) + XCTAssertEqual(change.metric, .stitches) + XCTAssertEqual(change.value, 1) + XCTAssertEqual(change.direction, .increased) + } + + @MainActor + func testIneffectiveDecrementDoesNotReplaceTheLastCounterChange() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = Project(name: "Scarf", sourceType: .pdf) + let timestamp = Date(timeIntervalSinceReferenceDate: 12_345) + project.recordLastCounterChange(metric: .repeatCount, value: 2, direction: .decreased, timestamp: timestamp) + let viewModel = PDFReadingViewModel() + + viewModel.decrementSelectedMetric(for: project, in: context) + + let change = try XCTUnwrap(project.lastCounterChange) + XCTAssertEqual(change.metric, .repeatCount) + XCTAssertEqual(change.value, 2) + XCTAssertEqual(change.direction, .decreased) + XCTAssertEqual(change.timestamp, timestamp) + } + + @MainActor + func testSelectingAnotherMetricDoesNotChangeTheLastCounterChange() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = Project(name: "Scarf", sourceType: .pdf) + let timestamp = Date(timeIntervalSinceReferenceDate: 12_345) + project.recordLastCounterChange(metric: .row, value: 4, direction: .increased, timestamp: timestamp) + let viewModel = PDFReadingViewModel() + + viewModel.selectMetric(.repeatCount, for: project, in: context) + + XCTAssertEqual(viewModel.selectedMetric, .repeatCount) + XCTAssertEqual(project.lastCounterChange?.metric, .row) + XCTAssertEqual(project.lastCounterChange?.value, 4) + XCTAssertEqual(project.lastCounterChange?.timestamp, timestamp) + } + + @MainActor + func testDirectRowSelectionRecordsANeutralCounterChange() throws { + let container = try makeContainer() + let context = ModelContext(container) + let project = Project( + name: "Scarf", + sourceType: .text, + rows: ["Row 1", "Row 2", "Row 3"] + ) + let viewModel = ReadingModeViewModel() + + viewModel.selectRow(at: 2, in: project, modelContext: context) + + let change = try XCTUnwrap(project.lastCounterChange) + XCTAssertEqual(change.metric, .row) + XCTAssertEqual(change.value, 2) + XCTAssertEqual(change.direction, .set) + } + + @MainActor + func testLastCounterChangePersistsAndProjectsRemainIndependent() throws { + let container = try makeContainer() + let context = ModelContext(container) + let firstProject = Project(name: "Scarf", sourceType: .text) + let secondProject = Project(name: "Socks", sourceType: .text) + firstProject.recordLastCounterChange(metric: .stitches, value: 24, direction: .increased) + context.insert(firstProject) + context.insert(secondProject) + try context.save() + + let freshContext = ModelContext(container) + let savedProjects = try freshContext.fetch(FetchDescriptor()) + let savedFirstProject = try XCTUnwrap(savedProjects.first { $0.id == firstProject.id }) + let savedSecondProject = try XCTUnwrap(savedProjects.first { $0.id == secondProject.id }) + + XCTAssertEqual(savedFirstProject.lastCounterChange?.metric, .stitches) + XCTAssertEqual(savedFirstProject.lastCounterChange?.value, 24) + XCTAssertEqual(savedFirstProject.lastCounterChange?.direction, .increased) + XCTAssertNotNil(savedFirstProject.lastCounterChange?.timestamp) + XCTAssertNil(savedSecondProject.lastCounterChange) + } + + func testLastCounterChangeTimestampUsesTimeTodayAndDateForOlderChanges() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let locale = Locale(identifier: "en_US") + let now = calendar.date(from: DateComponents(year: 2026, month: 10, day: 18, hour: 17, minute: 48)) ?? .now + let today = calendar.date(byAdding: .minute, value: -1, to: now) ?? now + let yesterday = calendar.date(byAdding: .day, value: -1, to: now) ?? now + + let todayText = LastCounterChangeFormatter.timestampText(for: today, now: now, calendar: calendar, locale: locale) + let olderText = LastCounterChangeFormatter.timestampText(for: yesterday, now: now, calendar: calendar, locale: locale) + + XCTAssertFalse(todayText.contains("2026")) + XCTAssertTrue(olderText.contains("2026")) + } + private func makeContainer() throws -> ModelContainer { try ModelContainer( for: Project.self, ProjectNote.self, From e8bb7e27e0b809e9d0a34c19383e9277d5950268 Mon Sep 17 00:00:00 2001 From: Dominik Moreira Date: Sun, 18 Oct 2026 19:56:15 +0100 Subject: [PATCH 3/4] bump buildversion --- LoopLine.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LoopLine.xcodeproj/project.pbxproj b/LoopLine.xcodeproj/project.pbxproj index fe656dd..f2c6517 100644 --- a/LoopLine.xcodeproj/project.pbxproj +++ b/LoopLine.xcodeproj/project.pbxproj @@ -406,7 +406,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = ZRUVWXD37Y; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; @@ -450,7 +450,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = ZRUVWXD37Y; ENABLE_APP_SANDBOX = YES; ENABLE_PREVIEWS = YES; From 0733cbbb2cfbd18c0858d9898cb7cab92119a9b5 Mon Sep 17 00:00:00 2001 From: Dominik Moreira Date: Sun, 18 Oct 2026 20:39:21 +0100 Subject: [PATCH 4/4] Share same counter between reading-modes --- Localizable.xcstrings | 4 + .../ViewModels/ReadingModeViewModel.swift | 55 ----- LoopLine/Views/ImageReadingView.swift | 18 +- LoopLine/Views/LoopLineUIComponents.swift | 128 +++++++++++ LoopLine/Views/PDFReadingView.swift | 128 +---------- LoopLine/Views/ReadingModeView.swift | 211 ++---------------- 6 files changed, 168 insertions(+), 376 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index ed548d4..87a4e36 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -999,6 +999,7 @@ }, "of %lld" : { "comment" : "Text appended to the end of a counter value to indicate the total number of rows. The argument is the current row number, the second is the total number of rows.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -1351,6 +1352,7 @@ }, "REPEAT" : { "comment" : "Label for the number of repeats.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -1443,6 +1445,7 @@ }, "ROW" : { "comment" : "Label for the number of the current row.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { @@ -1668,6 +1671,7 @@ }, "STITCHES" : { "comment" : "Label for the number of stitches counter in the reading controls.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "de" : { diff --git a/LoopLine/ViewModels/ReadingModeViewModel.swift b/LoopLine/ViewModels/ReadingModeViewModel.swift index 17e6693..8b25167 100644 --- a/LoopLine/ViewModels/ReadingModeViewModel.swift +++ b/LoopLine/ViewModels/ReadingModeViewModel.swift @@ -20,10 +20,6 @@ final class ReadingModeViewModel { appSettings.largeControls ? .title3 : .body } - func totalRows(for project: Project) -> Int { - project.rows.count - } - func activeRowIndex(for project: Project) -> Int? { guard !project.rows.isEmpty else { return nil } return min(max(project.currentRow, 0), project.rows.count - 1) @@ -46,16 +42,6 @@ final class ReadingModeViewModel { ) } - func canIncreaseRow(for project: Project) -> Bool { - let totalRows = totalRows(for: project) - return totalRows == 0 || project.currentRow < totalRows - 1 - } - - func canIncreaseRepeat(for project: Project) -> Bool { - guard let repeatTotal = project.repeatTotal else { return true } - return project.repeatCurrent < max(repeatTotal - 1, 0) - } - func selectRow(at index: Int, in project: Project, modelContext: ModelContext) { guard project.rows.indices.contains(index), project.currentRow != index else { return } project.currentRow = index @@ -63,47 +49,6 @@ final class ReadingModeViewModel { save(modelContext) } - func incrementRow(for project: Project, in modelContext: ModelContext) { - guard canIncreaseRow(for: project) else { return } - project.currentRow += 1 - project.recordLastCounterChange(metric: .row, value: project.currentRow, direction: .increased) - save(modelContext) - } - - func decrementRow(for project: Project, in modelContext: ModelContext) { - guard project.currentRow > 0 else { return } - project.currentRow -= 1 - project.recordLastCounterChange(metric: .row, value: project.currentRow, direction: .decreased) - save(modelContext) - } - - func incrementRepeat(for project: Project, in modelContext: ModelContext) { - guard canIncreaseRepeat(for: project) else { return } - project.repeatCurrent += 1 - project.recordLastCounterChange(metric: .repeatCount, value: project.repeatCurrent, direction: .increased) - save(modelContext) - } - - func decrementRepeat(for project: Project, in modelContext: ModelContext) { - guard project.repeatCurrent > 0 else { return } - project.repeatCurrent -= 1 - project.recordLastCounterChange(metric: .repeatCount, value: project.repeatCurrent, direction: .decreased) - save(modelContext) - } - - func incrementStitch(for project: Project, in modelContext: ModelContext) { - project.currentStitch += 1 - project.recordLastCounterChange(metric: .stitches, value: project.currentStitch, direction: .increased) - save(modelContext) - } - - func decrementStitch(for project: Project, in modelContext: ModelContext) { - guard project.currentStitch > 0 else { return } - project.currentStitch -= 1 - project.recordLastCounterChange(metric: .stitches, value: project.currentStitch, direction: .decreased) - save(modelContext) - } - func resetCounters(for project: Project, in modelContext: ModelContext) { let didChange = project.currentRow != 0 || project.repeatCurrent != 0 || project.currentStitch != 0 project.currentRow = 0 diff --git a/LoopLine/Views/ImageReadingView.swift b/LoopLine/Views/ImageReadingView.swift index 94db716..c1fa685 100644 --- a/LoopLine/Views/ImageReadingView.swift +++ b/LoopLine/Views/ImageReadingView.swift @@ -1,11 +1,22 @@ +import SwiftData import SwiftUI struct ImageReadingView: View { - let project: Project + @Query private var settings: [AppSettings] + @Bindable var project: Project private let viewModel = ImageReadingViewModel() + private var usesLargeControls: Bool { + settings.first?.largeControls ?? true + } + var body: some View { - Group { + VStack(spacing: 0) { + ReadingTrackingControls(project: project, usesLargeControls: usesLargeControls) + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 10) + if let imageReference = viewModel.imageReference(for: project) { ScrollView([.vertical, .horizontal]) { StoredImagePreview( @@ -16,7 +27,6 @@ struct ImageReadingView: View { .padding(18) .background(LoopLineTheme.surface, in: RoundedRectangle(cornerRadius: LoopLineTheme.cornerRadius, style: .continuous)) } - .background(LoopLineTheme.readingBackground.ignoresSafeArea()) .overlay(alignment: .bottomLeading) { Label("Pinch to zoom - drag to pan", systemImage: "arrow.up.left.and.arrow.down.right") .font(.caption.monospaced()) @@ -33,9 +43,9 @@ struct ImageReadingView: View { description: Text("The imported image could not be found.") ) .foregroundStyle(LoopLineTheme.primaryText, LoopLineTheme.accent, LoopLineTheme.secondaryText) - .background(LoopLineTheme.readingBackground.ignoresSafeArea()) } } + .background(LoopLineTheme.readingBackground.ignoresSafeArea()) .navigationTitle("Reading Mode") .navigationBarTitleDisplayMode(.inline) .toolbarBackground(LoopLineTheme.readingBackground, for: .navigationBar) diff --git a/LoopLine/Views/LoopLineUIComponents.swift b/LoopLine/Views/LoopLineUIComponents.swift index 87bc52f..3b91024 100644 --- a/LoopLine/Views/LoopLineUIComponents.swift +++ b/LoopLine/Views/LoopLineUIComponents.swift @@ -1,3 +1,4 @@ +import SwiftData import SwiftUI #if canImport(UIKit) @@ -354,3 +355,130 @@ struct LastCounterChangeLabel: View { .accessibilityLabel(statusText) } } + +struct ReadingTrackingControls: View { + @Environment(\.modelContext) private var modelContext + @Bindable var project: Project + let usesLargeControls: Bool + @State private var viewModel = PDFReadingViewModel() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + if usesLargeControls { + VStack(alignment: .leading, spacing: 12) { + metricPicker + + HStack(spacing: 20) { + decreaseButton + + metricValue + .frame(minWidth: 90) + + increaseButton + } + .frame(maxWidth: .infinity) + } + } else { + HStack(spacing: 16) { + metricPicker + + Spacer(minLength: 0) + + decreaseButton + + metricValue + .frame(minWidth: 70) + + increaseButton + } + } + + if let change = project.lastCounterChange { + LastCounterChangeLabel(change: change) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(LoopLineTheme.readingPanel, in: RoundedRectangle(cornerRadius: LoopLineTheme.cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: LoopLineTheme.cornerRadius, style: .continuous) + .stroke(LoopLineTheme.readingStroke, lineWidth: 1) + } + .onAppear { + viewModel.restoreSelectedMetric(for: project) + viewModel.normalizeTrackingValues(for: project, in: modelContext) + } + } + + private var metricPicker: some View { + Menu { + ForEach(ReadingTrackingMetric.allCases) { metric in + Button(metric.title) { + viewModel.selectMetric(metric, for: project, in: modelContext) + } + } + } label: { + HStack(spacing: 6) { + Text(viewModel.selectedMetric.title) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + + Image(systemName: "chevron.down") + .font(.caption.weight(.bold)) + } + .foregroundStyle(LoopLineTheme.readingPrimaryText) + .frame(minWidth: 92, alignment: .leading) + .padding(.horizontal, 12) + .frame(height: usesLargeControls ? 56 : 48) + .background(LoopLineTheme.readingControlFill, in: RoundedRectangle(cornerRadius: LoopLineTheme.compactCornerRadius, style: .continuous)) + } + .accessibilityLabel("Tracking metric") + .accessibilityValue(viewModel.selectedMetric.title) + } + + private var decreaseButton: some View { + Button { + viewModel.decrementSelectedMetric(for: project, in: modelContext) + } label: { + Image(systemName: "minus") + } + .buttonStyle(LoopLineIconButtonStyle( + size: usesLargeControls ? 70 : 48, + foregroundColor: LoopLineTheme.readingPrimaryText, + backgroundColor: LoopLineTheme.readingControlFill + )) + .disabled(!viewModel.canDecreaseSelectedMetric(for: project)) + .opacity(viewModel.canDecreaseSelectedMetric(for: project) ? 1 : 0.38) + .accessibilityLabel("Decrease \(viewModel.selectedMetric.title)") + } + + private var metricValue: some View { + Text(String(viewModel.selectedMetricValue(for: project))) + .font(metricValueFont) + .foregroundStyle(LoopLineTheme.readingPrimaryText) + .lineLimit(1) + .minimumScaleFactor(0.65) + .accessibilityLabel("\(viewModel.selectedMetric.title) \(viewModel.selectedMetricValue(for: project))") + } + + private var increaseButton: some View { + Button { + viewModel.incrementSelectedMetric(for: project, in: modelContext) + } label: { + Image(systemName: "plus") + } + .buttonStyle(LoopLineIconButtonStyle( + size: usesLargeControls ? 70 : 48, + foregroundColor: LoopLineTheme.primaryActionForeground, + backgroundColor: LoopLineTheme.primaryActionBackground + )) + .accessibilityLabel("Increase \(viewModel.selectedMetric.title)") + } + + private var metricValueFont: Font { + if usesLargeControls { + return Font.title.weight(.bold).monospacedDigit() + } + return Font.system(size: 34, weight: .bold).monospacedDigit() + } +} diff --git a/LoopLine/Views/PDFReadingView.swift b/LoopLine/Views/PDFReadingView.swift index 435af03..92b4288 100644 --- a/LoopLine/Views/PDFReadingView.swift +++ b/LoopLine/Views/PDFReadingView.swift @@ -20,7 +20,7 @@ struct PDFReadingView: View { Group { if let pdfURL = viewModel.pdfURL(for: project) { VStack(spacing: 0) { - trackingControls + ReadingTrackingControls(project: project, usesLargeControls: usesLargeControls) .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 10) @@ -54,7 +54,7 @@ struct PDFReadingView: View { .background(LoopLineTheme.readingBackground.ignoresSafeArea()) } else { VStack(spacing: 0) { - trackingControls + ReadingTrackingControls(project: project, usesLargeControls: usesLargeControls) .padding(.horizontal, 16) .padding(.top, 12) .padding(.bottom, 10) @@ -90,130 +90,6 @@ struct PDFReadingView: View { dismissButton: .default(Text("OK")) ) } - .onAppear { - viewModel.restoreSelectedMetric(for: project) - viewModel.normalizeTrackingValues(for: project, in: modelContext) - } - } - - private var trackingControls: some View { - VStack(alignment: .leading, spacing: 8) { - if usesLargeControls { - VStack(alignment: .leading, spacing: 12) { - metricPicker - - HStack(spacing: 20) { - decreaseButton - - metricValue - .frame(minWidth: 90) - - increaseButton - } - .frame(maxWidth: .infinity) - - if let change = project.lastCounterChange { - LastCounterChangeLabel(change: change) - } - } - } else { - HStack(spacing: 16) { - metricPicker - - Spacer(minLength: 0) - - decreaseButton - - metricValue - .frame(minWidth: 70) - - increaseButton - } - - if let change = project.lastCounterChange { - LastCounterChangeLabel(change: change) - } - } - } - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background(LoopLineTheme.readingPanel, in: RoundedRectangle(cornerRadius: LoopLineTheme.cornerRadius, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: LoopLineTheme.cornerRadius, style: .continuous) - .stroke(LoopLineTheme.readingStroke, lineWidth: 1) - } - } - - private var metricPicker: some View { - Menu { - ForEach(ReadingTrackingMetric.allCases) { metric in - Button(metric.title) { - viewModel.selectMetric(metric, for: project, in: modelContext) - } - } - } label: { - HStack(spacing: 6) { - Text(viewModel.selectedMetric.title) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - - Image(systemName: "chevron.down") - .font(.caption.weight(.bold)) - } - .foregroundStyle(LoopLineTheme.readingPrimaryText) - .frame(minWidth: 92, alignment: .leading) - .padding(.horizontal, 12) - .frame(height: usesLargeControls ? 56 : 48) - .background(LoopLineTheme.readingControlFill, in: RoundedRectangle(cornerRadius: LoopLineTheme.compactCornerRadius, style: .continuous)) - } - .accessibilityLabel("Tracking metric") - .accessibilityValue(viewModel.selectedMetric.title) - } - - private var decreaseButton: some View { - Button { - viewModel.decrementSelectedMetric(for: project, in: modelContext) - } label: { - Image(systemName: "minus") - } - .buttonStyle(LoopLineIconButtonStyle( - size: usesLargeControls ? 70 : 48, - foregroundColor: LoopLineTheme.readingPrimaryText, - backgroundColor: LoopLineTheme.readingControlFill - )) - .disabled(!viewModel.canDecreaseSelectedMetric(for: project)) - .opacity(viewModel.canDecreaseSelectedMetric(for: project) ? 1 : 0.38) - .accessibilityLabel("Decrease \(viewModel.selectedMetric.title)") - } - - private var metricValue: some View { - Text(String(viewModel.selectedMetricValue(for: project))) - .font(metricValueFont) - .foregroundStyle(LoopLineTheme.readingPrimaryText) - .lineLimit(1) - .minimumScaleFactor(0.65) - .accessibilityLabel("\(viewModel.selectedMetric.title) \(viewModel.selectedMetricValue(for: project))") - } - - private var increaseButton: some View { - Button { - viewModel.incrementSelectedMetric(for: project, in: modelContext) - } label: { - Image(systemName: "plus") - } - .buttonStyle(LoopLineIconButtonStyle( - size: usesLargeControls ? 70 : 48, - foregroundColor: LoopLineTheme.primaryActionForeground, - backgroundColor: LoopLineTheme.primaryActionBackground - )) - .accessibilityLabel("Increase \(viewModel.selectedMetric.title)") - } - - private var metricValueFont: Font { - if usesLargeControls { - return Font.title.weight(.bold).monospacedDigit() - } - return Font.system(size: 34, weight: .bold).monospacedDigit() } } diff --git a/LoopLine/Views/ReadingModeView.swift b/LoopLine/Views/ReadingModeView.swift index 221da16..da82968 100644 --- a/LoopLine/Views/ReadingModeView.swift +++ b/LoopLine/Views/ReadingModeView.swift @@ -18,13 +18,22 @@ struct ReadingModeView: View { @Bindable var viewModel = viewModel ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 16) { - readingPanel - } + VStack(spacing: 0) { + ReadingTrackingControls( + project: project, + usesLargeControls: appSettings.largeControls + ) .padding(.horizontal, 18) - .padding(.top, 18) - .padding(.bottom, appSettings.largeControls ? 340 : 290) + .padding(.top, 12) + .padding(.bottom, 10) + + ScrollView { + VStack(alignment: .leading, spacing: 16) { + readingPanel + } + .padding(.horizontal, 18) + .padding(.top, 8) + } } .background(readingBackground.ignoresSafeArea()) .navigationTitle("Reading Mode") @@ -40,10 +49,12 @@ struct ReadingModeView: View { .accessibilityLabel("Reset counters") } .safeAreaInset(edge: .bottom) { - readingControls + reminderControls } .onAppear { - viewModel.scrollToActiveRow(for: project, with: proxy) + DispatchQueue.main.async { + viewModel.scrollToActiveRow(for: project, with: proxy) + } } .onChange(of: project.currentRow) { _, _ in viewModel.scrollToActiveRow(for: project, with: proxy) @@ -149,59 +160,11 @@ struct ReadingModeView: View { .frame(maxWidth: .infinity, minHeight: 360) } - private var readingControls: some View { + private var reminderControls: some View { VStack(spacing: 12) { Divider() .overlay(dividerColor) - CounterControlPanel( - label: "ROW", - value: String(project.currentRow), - detail: viewModel.totalRows(for: project) > 0 - ? String(localized: "of \(viewModel.totalRows(for: project))") - : nil, - isPrimary: true, - usesLargeControls: appSettings.largeControls, - canDecrease: project.currentRow > 0, - canIncrease: viewModel.canIncreaseRow(for: project), - decreaseAction: { viewModel.decrementRow(for: project, in: modelContext) }, - increaseAction: { viewModel.incrementRow(for: project, in: modelContext) } - ) - - Divider() - .overlay(dividerColor) - - CounterControlPanel( - label: "REPEAT", - value: String(project.repeatCurrent), - detail: project.repeatTotal.map { String(localized: "of \($0)") }, - isPrimary: false, - usesLargeControls: appSettings.largeControls, - canDecrease: project.repeatCurrent > 0, - canIncrease: viewModel.canIncreaseRepeat(for: project), - decreaseAction: { viewModel.decrementRepeat(for: project, in: modelContext) }, - increaseAction: { viewModel.incrementRepeat(for: project, in: modelContext) } - ) - - Divider() - .overlay(dividerColor) - - CounterControlPanel( - label: "STITCHES", - value: String(project.currentStitch), - detail: nil, - isPrimary: false, - usesLargeControls: appSettings.largeControls, - canDecrease: project.currentStitch > 0, - canIncrease: true, - decreaseAction: { viewModel.decrementStitch(for: project, in: modelContext) }, - increaseAction: { viewModel.incrementStitch(for: project, in: modelContext) } - ) - - if let change = project.lastCounterChange { - LastCounterChangeLabel(change: change) - } - reminderStrip } .padding(.horizontal, 18) @@ -287,10 +250,6 @@ struct ReadingModeView: View { LoopLineTheme.readingPrimaryText } - private var secondaryText: Color { - LoopLineTheme.readingSecondaryText - } - private var dividerColor: Color { LoopLineTheme.readingStroke } @@ -352,136 +311,6 @@ private struct ReadingRow: View { } } -private struct CounterControlPanel: View { - let label: LocalizedStringResource - let value: String - let detail: String? - let isPrimary: Bool - let usesLargeControls: Bool - let canDecrease: Bool - let canIncrease: Bool - let decreaseAction: () -> Void - let increaseAction: () -> Void - - private var buttonSize: CGFloat { - if isPrimary { - return usesLargeControls ? 88 : 62 - } - return usesLargeControls ? 70 : 48 - } - - var body: some View { - if usesLargeControls { - VStack(alignment: .leading, spacing: 10) { - Text(label) - .font(.caption.weight(.bold)) - .foregroundStyle(secondaryText) - - HStack(spacing: 20) { - decreaseButton - - counterValue - .frame(minWidth: isPrimary ? 96 : 76) - - increaseButton - } - .frame(maxWidth: .infinity) - } - } else { - HStack(spacing: 16) { - Text(label) - .font(.caption.weight(.bold)) - .foregroundStyle(secondaryText) - .frame(width: 72, alignment: .leading) - - decreaseButton - - counterValue - .frame(minWidth: isPrimary ? 74 : 58) - - increaseButton - - Spacer(minLength: 0) - } - } - } - - private var decreaseButton: some View { - Button(action: decreaseAction) { - Image(systemName: "minus") - } - .buttonStyle(LoopLineIconButtonStyle( - size: buttonSize, - foregroundColor: primaryText, - backgroundColor: secondaryButtonBackground - )) - .disabled(!canDecrease) - .opacity(canDecrease ? 1 : 0.38) - } - - private var increaseButton: some View { - Button(action: increaseAction) { - Image(systemName: "plus") - } - .buttonStyle(LoopLineIconButtonStyle( - size: buttonSize, - foregroundColor: primaryIncreaseForeground, - backgroundColor: primaryIncreaseBackground - )) - .disabled(!canIncrease) - .opacity(canIncrease ? 1 : 0.38) - } - - private var counterValue: some View { - VStack(spacing: 1) { - Text(value) - .font(valueFont) - .foregroundStyle(primaryText) - .lineLimit(1) - .minimumScaleFactor(0.6) - - if let detail { - Text(detail) - .font(.caption.monospacedDigit()) - .foregroundStyle(secondaryText) - } - } - } - - private var valueFont: Font { - if usesLargeControls { - return (isPrimary ? Font.largeTitle : Font.title).weight(.bold).monospacedDigit() - } - return (isPrimary ? Font.system(size: 48, weight: .bold) : Font.system(size: 34, weight: .bold)).monospacedDigit() - } - - private var primaryText: Color { - LoopLineTheme.readingPrimaryText - } - - private var secondaryText: Color { - LoopLineTheme.readingSecondaryText - } - - private var secondaryButtonBackground: Color { - LoopLineTheme.readingControlFill - } - - private var primaryIncreaseForeground: Color { - if isPrimary { - return LoopLineTheme.primaryActionForeground - } - return primaryText - } - - private var primaryIncreaseBackground: Color { - if isPrimary { - return LoopLineTheme.primaryActionBackground - } - return secondaryButtonBackground - } -} - #Preview("Rows") { NavigationStack { ReadingModeView(project: Project(