ChartHistoryView.swift (11388B)
1 /* 2 * This file is part of GNU Taler, ©2022-25 Taler Systems S.A. 3 * See LICENSE.md 4 */ 5 /** 6 * @author Marc Stibane 7 */ 8 import SwiftUI 9 import Charts 10 import os.log 11 import SymLog 12 import taler_swift 13 14 fileprivate let lineWidth = 6.0 15 16 func yTransform(_ yVal: Double) -> Double { 17 #if OIMx 18 log2(yVal) 19 #else 20 yVal 21 #endif 22 } 23 24 // MARK: - 25 struct ChartHistoryBadge: View { 26 let talerTX: TalerTransaction? 27 28 @Environment(\.colorScheme) private var colorScheme 29 @Environment(\.colorSchemeContrast) private var colorSchemeContrast 30 31 var body: some View { 32 let isDark = colorScheme == .dark 33 let increasedContrast = colorSchemeContrast == .increased 34 if let talerTX { 35 TransactionIconBadge(from: talerTX, isDark: isDark, increasedContrast) 36 .talerFont(.largeTitle) 37 } 38 } 39 } 40 41 // MARK: - 42 @available(iOS 16.4, *) 43 struct ChartHistoryView: View { 44 let stack: CallStack 45 private let logger = Logger(subsystem: "net.taler.gnu", category: "Chart") 46 let currency: OIMcurrency 47 @Binding var shownItems: [HistoryItem] 48 @Binding var dataPointWidth: CGFloat 49 let scrollBack: Bool 50 51 let maxXValue: Int // #of dataPoints in history, plus spacers 52 @State private var maxYValue: Double // max balance 53 54 @Namespace var chartID 55 @State private var scrollPosition: Double = 0 56 @State private var selectedTX: Int? = nil 57 @State private var selectedY: Double? = nil 58 @State private var selectedRange: ClosedRange<Double>? 59 @State private var lastDelta: Double? 60 61 init(stack: CallStack, 62 currency: OIMcurrency, shownItems: Binding<[HistoryItem]>, dataPointWidth: Binding<CGFloat>, 63 scrollBack: Bool, maxIndex: Int = 1, maxValue: Double = 200 64 ) { 65 self.stack = stack 66 self.currency = currency 67 self.scrollBack = scrollBack 68 self._shownItems = shownItems 69 self._dataPointWidth = dataPointWidth 70 self.maxYValue = maxValue 71 self.maxXValue = maxIndex 72 } 73 74 var itemForSelection: HistoryItem? { 75 if let selectedTX { 76 for item in shownItems { 77 if item.distance == -selectedTX { 78 return item 79 } 80 } 81 } 82 return nil 83 } 84 85 func isTapped(y: Double) -> Bool { 86 if let selectedY { 87 return abs(y - selectedY) < 5 88 } 89 return false 90 } 91 92 func magnify(_ newDelta: Double) -> Bool { 93 if let lastDelta { 94 let delta = newDelta - lastDelta 95 let absDelta = abs(delta) 96 print(absDelta.pTwo) 97 var width = dataPointWidth 98 if delta > 0.1 { // zoom in 99 // width += 10 100 width += absDelta * 10 101 dataPointWidth = width < DATAPOINTWIDTH_MAX ? width : DATAPOINTWIDTH_MAX 102 } else if delta < -0.1 { // zoom out 103 // width -= 10 104 width -= absDelta * 10 105 dataPointWidth = width > DATAPOINTWIDTH_MIN ? width : DATAPOINTWIDTH_MIN 106 } else { 107 return false 108 } 109 } 110 return true 111 } 112 113 var body: some View { 114 let count = shownItems.count 115 let width = dataPointWidth * CGFloat(maxXValue) 116 // let _ = logger.log("ChartHistoryView \(width.pTwo)") 117 let chart = Chart { 118 ForEach(0..<count, id: \.self) { index in 119 let item = shownItems[index] 120 let xVal = -item.distance 121 let yVal = yTransform(item.balance) 122 LineMark(x: .value("x", xVal), 123 y: .value("y", yVal)) 124 .foregroundStyle(WalletColors().talerColor) 125 .lineStyle(StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) 126 // 0 = uniform spline, 0.5 = centripetal spline, 1.0 = chordal spline 127 // See https://en.wikipedia.org/wiki/File:Catmull-Rom_examples_with_parameters..png 128 // .interpolationMethod(.catmullRom(alpha: 0.5)) // centripetal 129 .interpolationMethod(.monotone) 130 // .interpolationMethod(.stepCenter) // stepStart 131 132 if let talerTX = item.talerTX { 133 PointMark(x: .value("x", xVal), 134 y: .value("y", yVal)) 135 136 .symbolSize(0) // hide the existing symbol 137 .annotation(position: .overlay, alignment: .center) { 138 let isSelected = xVal == selectedTX 139 let isTapped = isTapped(y: yVal) 140 if isSelected && isTapped { 141 let _ = logger.log("annotation \(xVal) \(yVal.pTwo) \(selectedY ?? 0)") 142 } 143 ChartHistoryBadge(talerTX: talerTX) 144 .id(index) 145 .scaleEffect(isSelected ? 1.8 : 1.0) 146 } 147 } else if xVal == 0 { 148 // let _ = logger.log("PointMark \(xVal) \(yVal.pTwo)") 149 PointMark(x: .value("x", xVal), 150 y: .value("y", yVal)) 151 .symbol(symbol: { 152 Image(systemName: "banknote.fill") // wallet.bifold.fill 153 .resizable() 154 .scaledToFit() 155 .frame(width: 25) 156 .foregroundStyle(WalletColors().talerColor) 157 }) 158 } 159 } 160 }.id(chartID) 161 .chartXScale(domain: -Double(maxXValue)-0.4...0.1) 162 .chartXAxis { } 163 .chartYScale(domain: -yTransform(maxYValue / 10)...yTransform(maxYValue)) 164 .chartYAxis { 165 AxisMarks(preset: .aligned, position: .trailing) { _ in 166 AxisGridLine() 167 AxisValueLabel(horizontalSpacing: 16) 168 } 169 } 170 .chartLegend(.hidden) 171 .frame(width: width) 172 .onChange(of: selectedRange) { newRange in 173 if let newRange { // pinch started - or changed 174 let newDelta = newRange.upperBound - newRange.lowerBound 175 // logger.log("selectedRange \(newDelta)") 176 if magnify(newDelta) { 177 lastDelta = newDelta 178 } 179 } else { // pinch ended 180 lastDelta = nil 181 } 182 } 183 ZStack(alignment: .top) { 184 ScrollViewReader { scrollProxy in 185 OptimalSize(.horizontal) { 186 ScrollView(.horizontal) { 187 if #available(iOS 17.0, *) { 188 chart 189 .chartYSelection(value: $selectedY) 190 .chartXSelection(value: $selectedTX) 191 .chartXSelection(range: $selectedRange) 192 // .border(.red) 193 } else { 194 chart 195 } 196 }//.border(.blue) 197 .scrollBounceBehavior(.basedOnSize, axes: .horizontal) 198 .task(id: scrollBack) { 199 logger.log("Task") 200 if scrollBack { 201 DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 202 logger.log("Scrolling") 203 withAnimation() { // .easeOut(duration: 3.0) doesn't work, always uses standard timing 204 scrollProxy.scrollTo(chartID, anchor: .bottomTrailing) 205 } 206 } 207 } 208 } 209 210 .onTapGesture(count: 2) { 211 withAnimation(.easeOut(duration: 0.6)) { 212 dataPointWidth = 100 // reset the scale first 213 scrollProxy.scrollTo(chartID, anchor: .bottomTrailing) 214 } 215 } 216 } 217 // Button("Top") { withAnimation { proxy.scrollTo(topID) } } 218 } // ScrollViewReader 219 if let selectedTX { 220 if let item = itemForSelection { 221 if let talerTX = item.talerTX { 222 ChartOverlayV(stack: stack.push(), 223 currency: currency, 224 balance: item.balance, 225 talerTX: talerTX) 226 } 227 } 228 } 229 } // ZStack 230 } 231 } 232 // MARK: - 233 @available(iOS 16.4, *) 234 struct ChartOverlayV: View { 235 let stack: CallStack 236 let currency: OIMcurrency 237 let balance: Double 238 let talerTX: TalerTransaction 239 let effective: Amount 240 241 @StateObject private var cash: OIMcash 242 @State var amountVal: UInt64 = 0 243 @Namespace var namespace 244 245 init(stack: CallStack, 246 currency: OIMcurrency, 247 balance: Double, 248 talerTX: TalerTransaction 249 ) { 250 self.stack = stack 251 self.currency = currency 252 self.balance = balance 253 self.talerTX = talerTX 254 let oimCash = OIMcash(currency) 255 self._cash = StateObject(wrappedValue: { oimCash }()) 256 let common = talerTX.common 257 let eff = common.amountEffective 258 self.effective = eff 259 let cents = eff.centValue 260 self._amountVal = State(wrappedValue: { cents }()) 261 // print("ChartOverlayV init:", cents) 262 } 263 264 var body: some View { 265 // TODO: hero animation from index 266 VStack { 267 Spacer() 268 let balance2 = balance * Double(currency.factor) 269 let incoming = talerTX.common.isIncoming 270 VStack { 271 Text("Balance: \(balance2.pTwo)") 272 .foregroundStyle(WalletColors().gray2) 273 OIMamountV(amount: effective, currencyName: currency.currencyStr, 274 factor: currency.factor, mayChangeSpeed: false) 275 OIMlineView(stack: stack.push(), 276 cash: cash, 277 amountVal: $amountVal, 278 canEdit: false) 279 // .opacity(isOpen ? 1 : INVISIBLE) 280 // .scaleEffect(scaleMoney ? 0.6 : 1) 281 }.environmentObject(NamespaceWrapper(namespace)) // keep OIMviews apart 282 .padding() 283 .onAppear { 284 cash.update2(amountVal) // set cash to talerTX.common.effective 285 } 286 .onChange(of: effective) { newVal in 287 print("ChartOverlayV onChange of:", newVal) 288 let cents = newVal.centValue 289 cash.update2(cents) // set cash to talerTX.common.effective 290 } 291 .background( 292 RoundedRectangle(cornerRadius: 30).style( 293 withStroke: Color.primary, 294 lineWidth: 2, 295 fill: LinearGradient( 296 gradient: Gradient(colors: [WalletColors().transactionColor(incoming), .white]), 297 startPoint: .top, 298 endPoint: .bottom 299 ).opacity(0.85) 300 ) 301 ) 302 Spacer() 303 } 304 } 305 }