diff --git a/PortfolioJournal.xcodeproj/project.pbxproj b/PortfolioJournal.xcodeproj/project.pbxproj index c6018a5..013b550 100644 --- a/PortfolioJournal.xcodeproj/project.pbxproj +++ b/PortfolioJournal.xcodeproj/project.pbxproj @@ -541,7 +541,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements; ENABLE_USER_SCRIPT_SANDBOXING = NO; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -582,7 +582,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal"; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; ENABLE_PREVIEWS = YES; @@ -739,7 +739,7 @@ ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -774,7 +774,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget"; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; @@ -805,7 +805,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -829,7 +829,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -852,7 +852,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -875,7 +875,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.6; @@ -899,7 +899,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_TEAM = 2825Q76T7H; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist; @@ -927,7 +927,7 @@ CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 79; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_TEAM = 2825Q76T7H; PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore"; GENERATE_INFOPLIST_FILE = NO; diff --git a/PortfolioJournal/Views/Charts/ChartZoom.swift b/PortfolioJournal/Views/Charts/ChartZoom.swift index bee7687..3f1682b 100644 --- a/PortfolioJournal/Views/Charts/ChartZoom.swift +++ b/PortfolioJournal/Views/Charts/ChartZoom.swift @@ -145,9 +145,16 @@ struct ChartRangeBrush: View { @Binding var selection: ClosedRange @State private var dragStartIndices: (lo: Int, hi: Int)? + @State private var dragTarget: DragTarget? @State private var lastTickedIndex: Int = -1 @State private var showingPickers = false + /// What a drag manipulates, decided ONCE at touch-down by where it starts. + /// One unified gesture instead of per-element gestures: with a narrow + /// window (e.g. 2 months) the handles' hit areas used to swallow the whole + /// window and moving it was impossible. + private enum DragTarget { case lowerHandle, upperHandle, moveWindow } + private static let labelFormatter: DateFormatter = { let f = DateFormatter() f.locale = .autoupdatingCurrent @@ -219,7 +226,7 @@ struct ChartRangeBrush: View { .frame(width: max(0, width - xHi)) .offset(x: xHi) - // Window frame — draggable as a whole to slide through history + // Window frame RoundedRectangle(cornerRadius: 6) .stroke(Color.appPrimary.opacity(0.7), lineWidth: 1.4) .background( @@ -227,15 +234,13 @@ struct ChartRangeBrush: View { ) .frame(width: max(10, xHi - xLo)) .offset(x: xLo) - .contentShape(Rectangle()) - .gesture(moveGesture(width: width)) - // Handles + // Handles (visual only — hit-testing lives in the unified gesture) handle(at: xLo) - .gesture(handleGesture(isLower: true, width: width)) handle(at: xHi) - .gesture(handleGesture(isLower: false, width: width)) } + .contentShape(Rectangle()) + .gesture(unifiedGesture(width: width)) } private func handle(at x: CGFloat) -> some View { @@ -245,9 +250,9 @@ struct ChartRangeBrush: View { .overlay( Capsule().stroke(Color(.systemBackground), lineWidth: 1.5) ) - .frame(width: 30, height: 44) // generous hit area - .contentShape(Rectangle()) - .offset(x: x - 15) + .frame(height: 44) + .offset(x: x - 2.5) + .allowsHitTesting(false) } // MARK: Geometry helpers @@ -263,22 +268,41 @@ struct ChartRangeBrush: View { return Int((frac * CGFloat(dates.count - 1)).rounded()) } - // MARK: Gestures + // MARK: Unified gesture + // + // Decided at touch-down: grabbing near a handle resizes that edge; grabbing + // anywhere else (inside the window OR on the dimmed history) slides the + // whole window keeping its width — select Jan–Mar, then swipe to land on + // Jun–Aug. Narrow windows stay movable because the edge grip only wins in + // its 18pt zone (from outside when the window is too narrow to share). - private func handleGesture(isLower: Bool, width: CGFloat) -> some Gesture { + private func unifiedGesture(width: CGFloat) -> some Gesture { DragGesture(minimumDistance: 1) .onChanged { value in let (lo, hi) = dragStartIndices ?? clampedSelection - if dragStartIndices == nil { dragStartIndices = (lo, hi) } - let i = index(atX: value.location.x, width: width) + if dragStartIndices == nil { + dragStartIndices = (lo, hi) + dragTarget = target( + forX: value.startLocation.x, + xLo: xPosition(index: lo, width: width), + xHi: xPosition(index: hi, width: width) + ) + } + guard let target = dragTarget else { return } var newLo = lo var newHi = hi - if isLower { - newLo = min(i, hi) - } else { - newHi = max(i, lo) + switch target { + case .lowerHandle: + newLo = min(index(atX: value.location.x, width: width), hi) + case .upperHandle: + newHi = max(index(atX: value.location.x, width: width), lo) + case .moveWindow: + let span = hi - lo + let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded()) + newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span)) + newHi = newLo + span } - let ticked = isLower ? newLo : newHi + let ticked = (target == .upperHandle) ? newHi : newLo if ticked != lastTickedIndex { lastTickedIndex = ticked ChartHaptics.tick() @@ -287,30 +311,24 @@ struct ChartRangeBrush: View { } .onEnded { _ in dragStartIndices = nil + dragTarget = nil lastTickedIndex = -1 } } - /// Dragging the window itself slides it through history keeping its width — - /// e.g. fix a 1-month window and replay the portfolio month by month. - private func moveGesture(width: CGFloat) -> some Gesture { - DragGesture(minimumDistance: 1) - .onChanged { value in - let (lo, hi) = dragStartIndices ?? clampedSelection - if dragStartIndices == nil { dragStartIndices = (lo, hi) } - let span = hi - lo - let deltaIdx = Int((value.translation.width / max(width, 1) * CGFloat(dates.count - 1)).rounded()) - let newLo = max(0, min(lo + deltaIdx, dates.count - 1 - span)) - if newLo != lastTickedIndex { - lastTickedIndex = newLo - ChartHaptics.tick() - } - selection = newLo...(newLo + span) - } - .onEnded { _ in - dragStartIndices = nil - lastTickedIndex = -1 - } + private func target(forX x: CGFloat, xLo: CGFloat, xHi: CGFloat) -> DragTarget { + let grip: CGFloat = 18 + let windowWidth = xHi - xLo + if windowWidth >= grip * 3 { + // Wide window: edges grab from either side of the handle. + if abs(x - xLo) <= grip { return .lowerHandle } + if abs(x - xHi) <= grip { return .upperHandle } + } else { + // Narrow window: the inside belongs to MOVE; edges only from outside. + if x < xLo && xLo - x <= grip { return .lowerHandle } + if x > xHi && x - xHi <= grip { return .upperHandle } + } + return .moveWindow } } diff --git a/PortfolioJournal/Views/Charts/ChartsContainerView.swift b/PortfolioJournal/Views/Charts/ChartsContainerView.swift index 830acba..08d6192 100644 --- a/PortfolioJournal/Views/Charts/ChartsContainerView.swift +++ b/PortfolioJournal/Views/Charts/ChartsContainerView.swift @@ -615,16 +615,11 @@ struct ChartsContainerView: View { if hasActiveFilters { activeFilterChip } + // No swipe-between-charts: it collided with every horizontal + // interaction (scrub, pan, brush). The chip bar navigates. chartContent - // Swipe left/right to move to the adjacent chart type. - // simultaneousGesture so charts with their own scrub drag - // don't swallow the swipe. - .simultaneousGesture(chartSwipeGesture) .id(viewModel.selectedChartType) - .transition(.asymmetric( - insertion: .move(edge: swipeInsertionEdge).combined(with: .opacity), - removal: .opacity - )) + .transition(.opacity) if viewModel.hiddenHistoryMonths > 0 { lockedHistoryTeaser } @@ -721,32 +716,11 @@ struct ChartsContainerView: View { } } - // MARK: - iPhone swipe between charts - /// Chart types in the same grouped order as the title switcher menu. private var orderedChartTypes: [ChartsViewModel.ChartType] { Self.chartGroups.flatMap { $0.types }.filter { showForecast || $0 != .prediction } } - @State private var swipeInsertionEdge: Edge = .trailing - - private var chartSwipeGesture: some Gesture { - DragGesture(minimumDistance: 30) - .onEnded { value in - // Horizontal-dominant swipes only, so vertical scroll still works. - guard abs(value.translation.width) > abs(value.translation.height) * 1.5 else { return } - let ordered = orderedChartTypes - guard let idx = ordered.firstIndex(of: viewModel.selectedChartType) else { return } - if value.translation.width < 0, idx < ordered.count - 1 { - swipeInsertionEdge = .trailing - withAnimation(.snappy) { viewModel.selectChart(ordered[idx + 1]) } - } else if value.translation.width > 0, idx > 0 { - swipeInsertionEdge = .leading - withAnimation(.snappy) { viewModel.selectChart(ordered[idx - 1]) } - } - } - } - /// Stocks-style segmented period control above the chart (the chart switcher /// lives in the title menu, filters in the toolbar), plus the global range /// brush toggle. The brush lives HERE — outside chartContent — so its drags