taler-ios

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

OIMtransactions.swift (12986B)


      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 taler_swift
     10 
     11 let DATAPOINTWIDTH = 60.0
     12 let DATAPOINTWIDTH_MIN = 25.0
     13 let DATAPOINTWIDTH_MAX = 200.0
     14 
     15 enum OIMtransactionsState {
     16     case chestIsOpen
     17     case chestOpenTapped        // <- move money
     18 
     19     case balanceTapped
     20     case historyShown
     21     case historyTapped
     22 
     23 }
     24 
     25 // MARK: -
     26 enum HistoryMarker: Int {
     27     case none
     28     case day
     29     case week
     30     case month
     31     case year
     32 }
     33 
     34 struct HistoryItem: Identifiable {
     35     var id: String {
     36         if let talerTX {
     37             return talerTX.id
     38         }
     39         return UUID().uuidString
     40     }
     41     let distance: Int
     42     let marker: HistoryMarker
     43     let balance: Double
     44     let talerTX: TalerTransaction?
     45 }
     46 
     47 // MARK: -
     48 @available(iOS 16.4, *)
     49 struct OIMtransactions: View {
     50     let stack: CallStack
     51 //    let decimal: Int            // 0 for ¥,HUF;   2 for $,€,£;   3 for ﷼,₯ (arabic)
     52     let balance: Balance                            // this is the currency to be used
     53     let cash: OIMcash
     54     let history: [TalerTransaction]
     55 
     56     @Environment(\.dismiss) var dismiss     // pop back once
     57     @EnvironmentObject private var controller: Controller
     58     @EnvironmentObject private var wrapper: NamespaceWrapper
     59     @AppStorage("oimChart") var oimChart: Bool = false                          // false = river
     60 
     61     @State private var availableVal: UInt64 = 0
     62     @State private var available: Amount? = nil
     63     @State private var viewState: OIMtransactionsState = .chestIsOpen
     64     @State private var closing = false                      // after user tapped on the open chest
     65     @State private var shownItems: [HistoryItem] = []
     66     @State private var computedItems: [HistoryItem] = []
     67     @State private var chartMaxY: Double = 200              // max balance
     68     @State private var dataPointWidth: CGFloat = DATAPOINTWIDTH
     69     // when we start fetching transactions in chunks, MinX will get negative (going back in time)
     70 
     71     func closeHistory() {
     72         withAnimation(.basic1) {
     73             viewState = .historyTapped
     74         }
     75         let delay0 = hideHistoryItems(oimChart ? Animation.talerDelay0 : 0.0)
     76         DispatchQueue.main.asyncAfter(deadline: .now() + delay0) {
     77             let delay = cash.flyOneByOne(to: .idle)             // back to center
     78             DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
     79 //                print("closeHistory", delay)
     80                 withAnimation(.basic1) {
     81                     viewState = .chestIsOpen
     82                 }
     83                 DispatchQueue.main.asyncAfter(deadline: .now() + Animation.talerDuration2) {
     84                     var transaction = Transaction()
     85                     transaction.disablesAnimations = true
     86                     withTransaction(transaction) {
     87                         dismiss()
     88                     }
     89                 }
     90             }
     91         }
     92     }
     93 
     94     @discardableResult
     95     func hideHistoryItems(_ interval: TimeInterval = 0) -> TimeInterval {
     96         if interval > 0 {
     97             DispatchQueue.main.asyncAfter(deadline: .now() + interval) {
     98                 let count = shownItems.count
     99                 if count > 0 {
    100                     withAnimation {
    101                         shownItems.removeLast()
    102                     }
    103                     if count > 1 {
    104                         hideHistoryItems(interval)
    105                     }
    106                 }
    107             }
    108             return Double(shownItems.count) * interval
    109         }
    110         shownItems = []
    111         return 0
    112     }
    113 
    114     @discardableResult
    115     func showHistoryItems(_ interval: TimeInterval = 0) -> TimeInterval {
    116         var delay = 0.0
    117         let count = computedItems.count
    118         let reduce = interval / Double(count + 2)
    119         var index = 0
    120         for item in computedItems {
    121             if interval > 0 {
    122                 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
    123                     withAnimation {
    124                         shownItems.append(item)
    125                     }
    126                 }
    127                 delay += (interval - Double(index) * reduce)
    128             } else {
    129                 shownItems.append(item)
    130             }
    131         }
    132         return delay
    133     }
    134 
    135     var body: some View {
    136         var debugTick = 0
    137 //        let _ = Self._printChanges()
    138 
    139         let sidePosition = Color.clear
    140                             .frame(width: 80, height: 80)
    141                             .matchedGeometryEffect(id: OIMSIDE, in: wrapper.namespace, isSource: true)
    142         let chartButtons = HStack {
    143             let a11yZoomOutStr = String(localized: "Zoom out", comment: "a11y for the zoom button")
    144             ZoomOutButton(accessibilityLabelStr: a11yZoomOutStr) {
    145                 let width = dataPointWidth - 10
    146                 if dataPointWidth > DATAPOINTWIDTH_MIN {
    147                     withAnimation(.easeInOut(duration: 0.6)) {
    148                         dataPointWidth = width > DATAPOINTWIDTH_MIN ? width : DATAPOINTWIDTH_MIN
    149                     }
    150                 }
    151             }//.buttonStyle(.borderedProminent)
    152 //            Text("scroll: \(scrollPosition.pTwo)  width: \(dataPointWidth.pTwo)  maxX: \(maxXValue.pTwo)")
    153 //            Text("width: \(dataPointWidth.pTwo)")
    154             let a11yZoomInStr = String(localized: "Zoom in", comment: "a11y for the zoom button")
    155             ZoomInButton(accessibilityLabelStr: a11yZoomInStr) {
    156                 let width = dataPointWidth + 15
    157                 if dataPointWidth < DATAPOINTWIDTH_MAX {
    158                     withAnimation(.easeInOut(duration: 0.6)) {
    159                         dataPointWidth = width < DATAPOINTWIDTH_MAX ? width : DATAPOINTWIDTH_MAX
    160                     }
    161                 }
    162             }//.buttonStyle(.borderedProminent)
    163         }
    164 
    165         OIMbackground() {
    166             VStack {
    167                 ZStack(alignment: .top) {
    168                     OIMtitleView(cash: cash,
    169                                amount: available,
    170                               history: true, //viewState == .historyShown,
    171                          secondAmount: nil)
    172 
    173                     if #unavailable(iOS 17.0) {
    174                         if oimChart {
    175                             chartButtons.padding(.top, 4)
    176    //                        .border(.red)
    177                         }
    178                     }
    179                 }
    180                 Spacer(minLength: 0)
    181                 ZStack {
    182                     HStack {
    183                         Spacer(minLength: 0)
    184                         let count = shownItems.count
    185                         if count > 1 {
    186                             let last = shownItems.last
    187                             let maxIndex = last?.distance ?? 20    // should never happen
    188                             let scrollBack = count == computedItems.count
    189 //        let _ = print("calling HistoryView", count, maxIndex, scrollBack)
    190                             if oimChart {
    191 #if TALER_WALLET
    192                                 ChartHistoryView(stack: stack.push(),
    193                                               currency: cash.currency,
    194                                             shownItems: $shownItems,
    195                                         dataPointWidth: $dataPointWidth,
    196                                             scrollBack: scrollBack,
    197                                               maxIndex: maxIndex,
    198                                               maxValue: chartMaxY)
    199 #else
    200                                 ArrowHistoryView(stack: stack.push(),
    201                                               currency: cash.currency,
    202                                             shownItems: $shownItems,
    203                                         dataPointWidth: $dataPointWidth,
    204                                             scrollBack: scrollBack,
    205                                               maxIndex: maxIndex,
    206                                               maxValue: chartMaxY)
    207 #endif
    208                             } else {
    209                                 RiverHistoryView(stack: stack.push(),
    210                                               currency: cash.currency,
    211                                             shownItems: $shownItems,
    212                                         dataPointWidth: $dataPointWidth,
    213                                             scrollBack: scrollBack,
    214                                               maxIndex: maxIndex,
    215                                               maxValue: chartMaxY)
    216                             }
    217                         }
    218                         sidePosition
    219                             .padding(.leading, 20)
    220                     }// .border(.blue)
    221                     OIMlineView(stack: stack.push(),
    222                                  cash: cash,
    223                             amountVal: $availableVal,
    224                               canEdit: false)
    225 //                  .opacity(isOpen ? 1 : INVISIBLE)
    226 //                  .scaleEffect(scaleMoney ? 0.6 : 1.0)
    227                     .onTapGesture {
    228                         closeHistory()
    229                     }
    230                 }
    231                 Spacer(minLength: 0)
    232             } // title, HStack
    233         }
    234         .onAppear {
    235             available = balance.available
    236             availableVal = available?.centValue ?? 0                            // TODO: centValue factor
    237             cash.update2(availableVal)           // set cash to available
    238             cash.setTarget(.history)
    239             debugTick += 1
    240             if computedItems.count == 0 {
    241                 computeData(from: history, balance: balance.available.value)        // set chartMaxY, computedData
    242             }
    243             showHistoryItems(oimChart ? Animation.talerDelay0 : 0.0)
    244         }
    245         .onDisappear {
    246 //            cash.moveBack()
    247             viewState = .chestIsOpen
    248         }
    249     }
    250 }
    251 // MARK: -
    252 @available(iOS 16.4, *)
    253 extension OIMtransactions {
    254 
    255     func computeData(from history: [TalerTransaction], balance: Double) {
    256         /// Algorithm for the X value of the river of time
    257         ///  Endpoint (now) is 0
    258         ///  same day transactions have no space between them
    259         ///  day differs: add 1 spacer
    260         ///  week differs: add another spacer
    261         ///  month differs: add one more spacer
    262         ///  year differs: add yet another spacer
    263         var historyItems: [HistoryItem] = [ HistoryItem(distance: 0, marker: .none, balance: balance, talerTX: nil) ]
    264         var balance = balance
    265         var maxBalance = balance
    266         let calendar = Calendar.current         // or do we need (identifier: .gregorian) ?
    267         var lastTx = calendar.dateComponents([.year, .month, .day, .weekOfMonth], from: Date.now)
    268         var xIndex = 0
    269 
    270         func historyItem(for talerTransaction: TalerTransaction) -> HistoryItem? {
    271             let common = talerTransaction.common
    272             let amount = common.amountEffective
    273             let incoming = common.isIncoming
    274             let pending = common.isPending
    275             let timestamp = common.timestamp
    276             let date = try! Date(milliseconds: timestamp.milliseconds())
    277             let components = calendar.dateComponents([.year, .month, .day, .weekOfMonth], from: date)
    278             // add spacers
    279             var marker: HistoryMarker = .none
    280             let isFirst = xIndex == 0
    281             if lastTx.year != components.year {
    282                 xIndex += isFirst ? 1 : 4
    283                 marker = .year
    284             } else if lastTx.month != components.month {
    285                 xIndex += isFirst ? 1 : 3
    286                 marker = .month
    287             } else if lastTx.weekOfMonth != components.weekOfMonth {
    288                 xIndex += isFirst ? 1 : 2
    289                 marker = .week
    290 //          } else if lastItem.weekday != components.weekday {
    291 //              index += 2
    292             } else if lastTx.day != components.day {
    293                 xIndex += 1
    294                 marker = .day
    295             }
    296             // advance to next index
    297             xIndex += 1
    298             // compute balance before that transaction
    299             if incoming {
    300                 balance -= amount.value
    301             } else {
    302                 balance += amount.value
    303             }
    304             lastTx = components
    305             if balance > maxBalance {
    306                 maxBalance = balance
    307             }
    308             return HistoryItem(distance: xIndex, marker: marker, balance: balance, talerTX: talerTransaction)
    309         }
    310 
    311         for talerTransaction in history {
    312             if let item = historyItem(for: talerTransaction) {
    313                 historyItems.append(item)
    314             }
    315         }
    316 
    317         computedItems = historyItems
    318         chartMaxY = maxBalance * yTransform(1.1)    // 10 % plus, log2()
    319 //        print("computeData:", history.count, xIndex)
    320     }
    321 }
    322 // MARK: -
    323 
    324 
    325 extension Double {
    326     var pTwo: String {
    327         String(format: "%.2f", self)
    328     }
    329 }
    330 extension CGFloat {
    331     var pTwo: String {
    332         String(format: "%.2f", self)
    333     }
    334 }
    335 extension CGSize {
    336     var pTwo: String {
    337         String(format: "%.2f, %.2f", self.width, self.height)
    338     }
    339 }