taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }